800 lines
33 KiB
Python
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 "<script>" 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
|