495 lines
21 KiB
Python
495 lines
21 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,
|
|
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"] = "<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
|
|
query = "requestId=PLM-D-FT-003-0001"
|
|
queried = await client.get(PATH + "?" + query, headers=headers(method="GET", query=query))
|
|
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"]
|
|
|
|
|
|
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_query_report(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
|
|
query = "requestId=PLM-D-FT-003-0001"
|
|
fetched = await client.get(PATH + "?" + query, headers=headers(method="GET", query=query))
|
|
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
|