dieya_risk_prediction_backend/tests/test_integration_risk.py

800 lines
33 KiB
Python

"""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,
ConversationMessage,
IntegrationAssessment,
IntegrationConversation,
IntegrationConversationRequest,
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))
def chat_headers(path, body=b"", *, method="POST", query="", nonce=None, client="dieya-plm"):
timestamp = str(int(time.time()))
nonce = nonce or uuid.uuid4().hex
message = (
method.encode()
+ b"\n"
+ path.encode()
+ b"\n"
+ query.encode()
+ b"\n"
+ timestamp.encode()
+ b"\n"
+ nonce.encode()
+ b"\n"
+ body
)
return {
"Content-Type": "application/json",
"X-Signature-Version": "2",
"X-Client-Id": client,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": base64.b64encode(hmac.new(SECRET.encode(), message, hashlib.sha256).digest()).decode(),
}
@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)
cursor = connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
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"] == "application/json"
assert response.json()["data"]["reportMarkdown"].startswith("# 新品风险评估报告")
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_report_repost_returns_same_json_and_different_content_conflicts(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 == 405
@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"] = "<script>alert(1)</script>|[链接](javascript:test)"
response = await post(client, data)
assert response.status_code == 200
assert "无法归类" in response.text and "固定验证建议" in response.text
assert "<script>" not in response.text and "&lt;script&gt;" in response.text
async def test_nonce_is_consumed_even_when_body_invalid(integration_db):
client, sessions, _ = integration_db
data = payload()
del data["assessment"]["subjectId"]
assert (await post(client, data, nonce="invalid-body-nonce")).status_code == 422
assert (await post(client, nonce="invalid-body-nonce")).status_code == 401
async with sessions() as session:
assert await session.scalar(select(IntegrationNonce)) is not None
async def test_concurrent_same_request_does_not_start_another_assessment(integration_db, monkeypatch):
client, sessions, _ = integration_db
entered, release = asyncio.Event(), asyncio.Event()
original = integration_risk.ExternalRiskReportService.assess
calls = 0
async def blocked(self, payload, as_of):
nonlocal calls
calls += 1
entered.set()
await release.wait()
return await original(self, payload, as_of)
monkeypatch.setattr(integration_risk.ExternalRiskReportService, "assess", blocked)
first = asyncio.create_task(post(client))
try:
await asyncio.wait_for(entered.wait(), timeout=5)
pending = await post(client)
assert pending.status_code == 202
queried = await post(client)
assert queried.status_code == 202
finally:
release.set()
completed = await first
assert completed.status_code == 200 and calls == 1
async with sessions() as session:
assert len(list((await session.scalars(select(IntegrationAssessment))).all())) == 1
async def test_generation_timeout_is_retryable(integration_db, monkeypatch):
client, sessions, settings = integration_db
original = integration_risk.ExternalRiskReportService.assess
async def slow(self, payload, as_of):
await asyncio.sleep(1)
settings.integration_report_timeout_seconds = 0.01
monkeypatch.setattr(integration_risk.ExternalRiskReportService, "assess", slow)
assert (await post(client)).status_code == 503
async with sessions() as session:
assert (await session.scalar(select(IntegrationAssessment))).status == "failed"
settings.integration_report_timeout_seconds = 110
monkeypatch.setattr(integration_risk.ExternalRiskReportService, "assess", original)
assert (await post(client)).status_code == 200
async def test_completion_is_fenced_by_owner_token(integration_db):
client, sessions, _ = integration_db
assert (await post(client)).status_code == 200
async with sessions() as session:
record = await session.scalar(select(IntegrationAssessment))
record.status, record.owner_token = "processing", "new-owner"
await session.commit()
repository = IntegrationRiskRepository(session)
assert not await repository.finish(record.id, "old-owner", error="obsolete failure")
current = await repository.get("dieya-plm", "PLM-D-FT-003-0001")
assert current.status == "processing" and current.owner_token == "new-owner"
async def test_source_system_and_idempotency_header_must_match(integration_db):
client, _, _ = integration_db
data = payload()
data["sourceSystem"] = "other-system"
assert (await post(client, data)).status_code == 403
body = json.dumps(payload()).encode()
signed = headers(body)
signed["Idempotency-Key"] = "other-request"
assert (await client.post(PATH, content=body, headers=signed)).status_code == 422
async def test_openapi_exposes_request_contract(integration_db):
client, _, _ = integration_db
schema = (await client.get("/openapi.json")).json()
assert schema["paths"][PATH]["post"]["requestBody"]["required"]
assert "IntegrationRiskRequest" in schema["components"]["schemas"]
@pytest.mark.asyncio
async def test_third_party_contract_exposes_only_report_and_follow_up_posts(integration_db):
client, _, _ = integration_db
schema = (await client.get("/openapi.json")).json()
paths = {
path: set(operations)
for path, operations in schema["paths"].items()
if path.startswith("/api/v1/integrations/")
}
assert paths == {
"/api/v1/integrations/risk-assessments/report": {"post"},
"/api/v1/integrations/risk-assessments/follow-up": {"post"},
}
async def add_external_operator(sessions, settings):
async with sessions() as session:
session.add(UserAccount(emp_no="plm_superadmin", name="周子墨", role="user", status="启用"))
await session.commit()
settings.integration_operator_mappings = SecretStr(json.dumps({"dieya-plm": {"superadmin": "plm_superadmin"}}))
async def test_external_operator_can_generate_and_retrieve_report_by_repost(integration_db):
client, sessions, settings = integration_db
await add_external_operator(sessions, settings)
data = payload()
data["operator"] = {"externalUserId": "superadmin", "name": "伪造姓名"}
report = await post(client, data)
assert report.status_code == 200 and "周子墨" in report.text and "伪造姓名" not in report.text
fetched = await post(client, data)
assert fetched.status_code == 200 and fetched.text == report.text
async with sessions() as session:
user = await session.scalar(select(UserAccount).where(UserAccount.emp_no == "plm_superadmin"))
record = await session.scalar(select(IntegrationAssessment))
assert user.role == "user" and user.password_hash is None and user.dingtalk_union_id is None
assert record.user_id == user.id
@pytest.mark.parametrize("change", ["unknown", "other_client", "disabled", "permission", "config_revoked"])
async def test_external_operator_scope_and_permission_are_enforced(integration_db, change):
client, sessions, settings = integration_db
await add_external_operator(sessions, settings)
data = payload()
data["operator"] = {"externalUserId": "superadmin"}
if change == "unknown":
data["operator"]["externalUserId"] = "unknown"
elif change == "other_client":
data["sourceSystem"] = "other-system"
elif change == "config_revoked":
settings.integration_operator_mappings = SecretStr("{}")
else:
async with sessions() as session:
if change == "disabled":
await session.execute(
update(UserAccount).where(UserAccount.emp_no == "plm_superadmin").values(status="停用")
)
else:
await session.execute(update(RolePermission).values(can_operate=False))
await session.commit()
assert (await post(client, data, client=data["sourceSystem"])).status_code == 403
@pytest.mark.parametrize(
"operator",
[
{},
{"externalUserId": "superadmin", "dingtalkUnionId": "union-1"},
{"externalUserId": "superadmin", "dingtalkUserId": "ding-1"},
{"externalUserId": " "},
],
)
async def test_operator_identity_must_be_explicit_and_unambiguous(integration_db, operator):
client, _, _ = integration_db
data = payload()
data["operator"] = operator
assert (await post(client, data)).status_code == 422
async def test_existing_dingtalk_idempotency_digest_is_preserved(integration_db):
client, sessions, _ = integration_db
assert (await post(client)).status_code == 200
from app.schemas.integration_risk import IntegrationRiskRequest
old_data = IntegrationRiskRequest.model_validate(payload()).model_dump(mode="json", by_alias=True)
old_data["operator"].pop("externalUserId")
old_digest = hashlib.sha256(
json.dumps(old_data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
async with sessions() as session:
record = await session.scalar(select(IntegrationAssessment))
assert record.request_hash == old_digest
FOLLOW_UP_PATH = "/api/v1/integrations/risk-assessments/follow-up"
async def prepare_report(integration_db):
client, sessions, settings = integration_db
await add_external_operator(sessions, settings)
data = payload()
data["operator"] = {"externalUserId": "superadmin"}
report = await post(client, data)
assert report.status_code == 200, report.text
return report.json()["data"]
def follow_up_data(content="解释原报告的得分", message_id="QUESTION-001"):
return {
"requestId": "PLM-D-FT-003-0001",
"messageRequestId": message_id,
"operator": {"externalUserId": "superadmin"},
"content": content,
}
async def ask(client, data=None, *, signing_client="dieya-plm"):
data = data or follow_up_data()
body = json.dumps(data, ensure_ascii=False).encode()
signed = chat_headers(FOLLOW_UP_PATH, body, client=signing_client)
signed["Idempotency-Key"] = data["messageRequestId"]
return await client.post(FOLLOW_UP_PATH, content=body, headers=signed)
def mock_follow_up_model(monkeypatch, replies):
async def semantics(self, content, history=None):
replies.append(("semantic", content, history))
return {"intent": "general", "risk_requested": False, "prompt_key": "product_manager"}
async def reply(self, messages):
replies.append(("reply", messages[-1]["content"], messages))
yield "根据原报告,"
yield "请优先验证包装密封性。"
monkeypatch.setattr(LLMClient, "extract_semantics", semantics)
monkeypatch.setattr(LLMClient, "stream_reply", reply)
async def test_only_two_external_post_endpoints_are_exposed(integration_db):
client, _, _ = integration_db
paths = (await client.get("/openapi.json")).json()["paths"]
external = {path: set(methods) for path, methods in paths.items() if path.startswith("/api/v1/integrations/")}
assert external == {PATH: {"post"}, FOLLOW_UP_PATH: {"post"}}
for old_path in (
"/api/v1/integrations/conversations",
"/api/v1/integrations/conversations/1/messages",
"/api/v1/integrations/conversations/1/stream",
"/api/v1/integrations/conversations/1/requests/Q1",
):
assert (await client.post(old_path, json={})).status_code == 404
async def test_follow_up_auto_creates_one_conversation_and_returns_complete_json(integration_db, monkeypatch):
client, sessions, _ = integration_db
original = await prepare_report(integration_db)
calls = []
mock_follow_up_model(monkeypatch, calls)
first = await ask(client)
assert first.status_code == 200, first.text
assert first.headers["content-type"] == "application/json"
assert first.json() == {
"code": 200,
"message": "success",
"data": {
"requestId": original["requestId"],
"messageRequestId": "QUESTION-001",
"status": "completed",
"reply": "根据原报告,请优先验证包装密封性。",
},
}
assert "data:" not in first.text
second = await ask(client, follow_up_data("那么下一步呢?", "QUESTION-002"))
assert second.status_code == 200, second.text
assert second.json()["data"]["reply"] == first.json()["data"]["reply"]
assert len(calls) == 4
for _, _, context in calls:
systems = [item["content"] for item in context if item["role"] == "system"]
assert any("riskAssessmentResult" in item for item in systems)
second_reply_history = calls[-1][2]
assert any(message["content"] == "解释原报告的得分" for message in second_reply_history)
async with sessions() as session:
assert len(list(await session.scalars(select(IntegrationConversation)))) == 1
assert len(list(await session.scalars(select(ConversationMessage)))) == 4
assert len(list(await session.scalars(select(IntegrationConversationRequest)))) == 2
report = await session.scalar(select(IntegrationAssessment))
assert report.report_markdown == original["reportMarkdown"]
async def test_follow_up_same_request_replays_original_answer_and_conflicts_on_changed_content(
integration_db, monkeypatch
):
client, sessions, _ = integration_db
await prepare_report(integration_db)
calls = []
mock_follow_up_model(monkeypatch, calls)
first = await ask(client)
repeated = await ask(client)
assert repeated.json() == first.json()
assert len(calls) == 2
conflict = await ask(client, follow_up_data("改成另外一个问题"))
assert conflict.status_code == 409
async with sessions() as session:
assert len(list(await session.scalars(select(ConversationMessage)))) == 2
async def test_failed_follow_up_can_retry_the_same_endpoint_without_duplicate_question(integration_db, monkeypatch):
client, sessions, _ = integration_db
await prepare_report(integration_db)
async def fail(self, content, history=None):
raise RuntimeError("private error detail")
monkeypatch.setattr(LLMClient, "extract_semantics", fail)
failed = await ask(client)
assert failed.status_code == 503
assert "private error" not in failed.text
async with sessions() as session:
row = await session.scalar(select(IntegrationConversationRequest))
assert row.status == "failed" and row.assistant_message_id is None
assert len(list(await session.scalars(select(ConversationMessage)))) == 1
blocked = await ask(client, follow_up_data("新问题", "QUESTION-002"))
assert blocked.status_code == 409
calls = []
mock_follow_up_model(monkeypatch, calls)
completed = await ask(client)
assert completed.status_code == 200, completed.text
async with sessions() as session:
assert len(list(await session.scalars(select(ConversationMessage)))) == 2
async def test_concurrent_duplicate_returns_json_pending_and_different_question_is_blocked(integration_db, monkeypatch):
client, sessions, _ = integration_db
await prepare_report(integration_db)
entered, release = asyncio.Event(), asyncio.Event()
calls = []
mock_follow_up_model(monkeypatch, calls)
async def reply(self, messages):
entered.set()
await release.wait()
yield "完整回答"
monkeypatch.setattr(LLMClient, "stream_reply", reply)
task = asyncio.create_task(ask(client))
try:
await asyncio.wait_for(entered.wait(), 3)
pending = await ask(client)
assert pending.status_code == 202 and pending.headers["retry-after"] == "3"
assert pending.json()["data"]["status"] == "processing"
assert pending.json()["data"]["reply"] is None
blocked = await ask(client, follow_up_data("另外的问题", "QUESTION-002"))
assert blocked.status_code == 409
finally:
release.set()
result = await task
assert result.json()["data"]["reply"] == "完整回答"
assert (await ask(client)).json() == result.json()
@pytest.mark.parametrize("change", ["other_user", "other_client", "disabled", "permission", "missing_report"])
async def test_follow_up_rechecks_identity_report_ownership_and_permission(integration_db, change):
client, sessions, settings = integration_db
await prepare_report(integration_db)
data = follow_up_data()
signing_client = "dieya-plm"
if change == "other_user":
data["operator"] = {"dingtalkUnionId": "union-1"}
elif change == "other_client":
settings.integration_operator_mappings = SecretStr(
json.dumps(
{
"dieya-plm": {"superadmin": "plm_superadmin"},
"other-system": {"superadmin": "plm_superadmin"},
}
)
)
signing_client = "other-system"
elif change == "missing_report":
data["requestId"] = "MISSING"
else:
async with sessions() as session:
if change == "disabled":
await session.execute(
update(UserAccount).where(UserAccount.emp_no == "plm_superadmin").values(status="停用")
)
else:
await session.execute(update(RolePermission).values(can_operate=False))
await session.commit()
denied = await ask(client, data, signing_client=signing_client)
assert denied.status_code == (403 if change in {"disabled", "permission"} else 404), denied.text
async with sessions() as session:
assert list(await session.scalars(select(IntegrationConversation))) == []
async def test_follow_up_requires_correct_signature_and_idempotency_key(integration_db):
client, _, _ = integration_db
data = follow_up_data()
body = json.dumps(data).encode()
signed = chat_headers(FOLLOW_UP_PATH, body)
signed["Idempotency-Key"] = "WRONG"
assert (await client.post(FOLLOW_UP_PATH, content=body, headers=signed)).status_code == 422
legacy = headers(body)
legacy["Idempotency-Key"] = data["messageRequestId"]
assert (await client.post(FOLLOW_UP_PATH, content=body, headers=legacy)).status_code == 401
signed = chat_headers(FOLLOW_UP_PATH, body)
signed["Idempotency-Key"] = data["messageRequestId"]
assert (await client.post(FOLLOW_UP_PATH, content=body + b" ", headers=signed)).status_code == 401
signed = chat_headers(FOLLOW_UP_PATH, body)
signed["Idempotency-Key"] = data["messageRequestId"]
assert (await client.post(PATH, content=body, headers=signed)).status_code == 401
async def test_follow_up_timeout_is_json_and_old_worker_cannot_write(integration_db, monkeypatch):
from app.repositories.integration_conversations import IntegrationConversationRepository
from app.services import integration_follow_up
client, sessions, _ = integration_db
await prepare_report(integration_db)
mock_follow_up_model(monkeypatch, [])
async def slow(self, messages):
await asyncio.Event().wait()
yield "不会执行"
monkeypatch.setattr(LLMClient, "stream_reply", slow)
monkeypatch.setattr(
integration_follow_up, "get_settings", lambda: SimpleNamespace(integration_chat_timeout_seconds=0.05)
)
failed = await ask(client)
assert failed.status_code == 503 and failed.headers["content-type"] == "application/json"
async with sessions() as session:
repo = IntegrationConversationRepository(session)
row = await session.scalar(select(IntegrationConversationRequest))
old_token, conversation_id, user_id = row.owner_token, row.conversation_id, row.user_id
reclaimed, owned = await repo.claim_message(
client_id="dieya-plm",
conversation_id=conversation_id,
request_id="QUESTION-001",
user_id=user_id,
content=follow_up_data()["content"],
)
assert owned and reclaimed.owner_token != old_token
with pytest.raises(Exception) as error:
await repo.persist_reply(
client_id="dieya-plm",
conversation_id=conversation_id,
request_id="QUESTION-001",
token=old_token,
content="过期回答",
semantic={},
actor_emp_no="plm_superadmin",
)
assert getattr(error.value, "status_code", None) == 409
async with sessions() as session:
assert len(list(await session.scalars(select(ConversationMessage)))) == 1