Files
group_fqcd_jr/tests/integration/test_offsite_attachment_preview.py
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

384 lines
14 KiB
Python

"""场外附件原始文件预览接口的集成测试。
覆盖:PDF 申购单内联预览、JPG 等图片内联预览、media_type 缺失时按后缀兜底、
非预览类型(Excel、SVG)强制下载、附件不存在、文件越界或缺失、权限拦截,
以及预览动作必须留下审计记录。
统一在单事件循环内调用应用(httpx.ASGITransport),避免连接池里的连接被
跨事件循环复用。
"""
from collections.abc import AsyncIterator
from datetime import UTC, date, datetime
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.infrastructure.db import SessionFactory, engine
from app.main import app
from app.model.audit import InteractionAudit
from app.model.offsite_fund import OffsiteFundAttachment, OffsiteFundMail
PREVIEW_PATH = "/api/v1/offsite-fund/attachments/{attachment_id}/file"
PDF_BYTES = b"%PDF-1.4\n% offsite subscription preview\n"
JPEG_BYTES = b"\xff\xd8\xff\xe0offsite-jpeg-preview\xff\xd9"
WORKBOOK_BYTES = b"PK\x03\x04offsite-workbook"
SVG_BYTES = b"<svg xmlns='http://www.w3.org/2000/svg'><script/></svg>"
TRACE_ID = ""
@pytest.fixture(autouse=True)
async def _dispose_engine_after_test() -> AsyncIterator[None]:
"""用例结束后释放连接池,避免连接绑定在已关闭的事件循环上。"""
yield
await engine.dispose()
async def _override_session() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
def _install_context(roles: tuple[str, ...], permissions: tuple[str, ...]) -> None:
async def override_context() -> RequestContext:
return RequestContext(
user_id="1",
trace_id=TRACE_ID,
roles=roles,
permissions=permissions,
data_scope="all",
)
app.dependency_overrides[build_request_context] = override_context
app.dependency_overrides[get_session] = _override_session
async def _get_preview(attachment_id: str, **params: str) -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get(
PREVIEW_PATH.format(attachment_id=attachment_id), params=params or None
)
def _use_storage_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""把附件存储根切到临时目录,避免触碰真实收件目录。"""
root = tmp_path / "offsite-mail"
root.mkdir(parents=True, exist_ok=True)
settings = get_settings().model_copy(update={"offsite_mail_storage_dir": str(root)})
monkeypatch.setattr("app.service.offsite_fund_service.get_settings", lambda: settings)
return root
async def _seed_attachment(
file_path: Path,
filename: str,
media_type: str,
document_type: str = "subscription",
) -> tuple[str, str]:
mail_id = f"M{uuid4().hex[:12]}"
attachment_id = f"{mail_id}-A01"
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
session.add(OffsiteFundMail(
mail_id=mail_id,
imap_uid=f"uid-{mail_id}",
message_id=f"<{mail_id}@integration.local>",
received_date=date(2026, 9, 10),
sender="15008108550@163.com",
return_path="15008108550@163.com",
auth_result={"spf": "pass", "dkim": "pass"},
original_eml_path=str(file_path.parent / "message.eml"),
status="recognized",
retry_count=0,
created_at=now,
updated_at=now,
))
session.add(OffsiteFundAttachment(
attachment_id=attachment_id,
mail_id=mail_id,
filename=filename,
file_hash=uuid4().hex,
media_type=media_type,
size_bytes=file_path.stat().st_size if file_path.exists() else 0,
document_type=document_type,
original_file_path=str(file_path),
ocr_text="识别文本",
extracted_fields={"基金代码": "159511"},
field_confidence={"基金代码": "0.99"},
page_evidence={"基金代码": [1]},
status="recognized",
created_at=now,
))
return mail_id, attachment_id
async def _count_audit(action_type: str) -> int:
async with SessionFactory() as session:
rows = await session.execute(select(InteractionAudit).where(
InteractionAudit.action_type == action_type,
InteractionAudit.detail["trace_id"].as_string() == TRACE_ID,
))
return len(rows.scalars().all())
async def _cleanup(mail_id: str) -> None:
async with SessionFactory() as session, session.begin():
if TRACE_ID:
await session.execute(delete(InteractionAudit).where(
InteractionAudit.detail["trace_id"].as_string() == TRACE_ID
))
if mail_id:
await session.execute(
delete(OffsiteFundAttachment).where(OffsiteFundAttachment.mail_id == mail_id)
)
await session.execute(
delete(OffsiteFundMail).where(OffsiteFundMail.mail_id == mail_id)
)
@pytest.mark.integration
async def test_pdf_attachment_previews_inline(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
global TRACE_ID
TRACE_ID = f"trace-attachment-{uuid4()}"
root = _use_storage_root(tmp_path, monkeypatch)
file_path = root / "A01.pdf"
file_path.write_bytes(PDF_BYTES)
mail_id = ""
try:
mail_id, attachment_id = await _seed_attachment(
file_path, "申购申请单.pdf", "application/pdf"
)
_install_context(("operator",), ("offsite:read",))
response = await _get_preview(attachment_id)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
assert response.headers["content-disposition"].startswith("inline")
assert response.headers["x-content-type-options"] == "nosniff"
assert response.content == PDF_BYTES
assert await _count_audit("offsite.attachment_viewed") == 1
finally:
await _cleanup(mail_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_image_attachment_previews_inline(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""扫描件图片(JPG)必须能直接内联预览。"""
global TRACE_ID
TRACE_ID = f"trace-attachment-{uuid4()}"
root = _use_storage_root(tmp_path, monkeypatch)
file_path = root / "A01.jpg"
file_path.write_bytes(JPEG_BYTES)
mail_id = ""
try:
mail_id, attachment_id = await _seed_attachment(
file_path, "申购申请单扫描件.jpg", "image/jpeg"
)
_install_context(("operator",), ("offsite:read",))
response = await _get_preview(attachment_id)
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
assert response.headers["content-disposition"].startswith("inline")
assert response.content == JPEG_BYTES
finally:
await _cleanup(mail_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_pdf_media_type_falls_back_to_file_suffix(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""识别时 media_type 存成通用二进制,PDF 申购单仍然要能直接预览。"""
global TRACE_ID
TRACE_ID = f"trace-attachment-{uuid4()}"
root = _use_storage_root(tmp_path, monkeypatch)
file_path = root / "A01.pdf"
file_path.write_bytes(PDF_BYTES)
mail_id = ""
try:
mail_id, attachment_id = await _seed_attachment(
file_path, "赎回申请单.pdf", "application/octet-stream"
)
_install_context(("operator",), ("offsite:read",))
response = await _get_preview(attachment_id)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
assert response.headers["content-disposition"].startswith("inline")
assert response.content == PDF_BYTES
finally:
await _cleanup(mail_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_non_previewable_attachment_forces_download(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
global TRACE_ID
TRACE_ID = f"trace-attachment-{uuid4()}"
root = _use_storage_root(tmp_path, monkeypatch)
file_path = root / "A01.xlsx"
file_path.write_bytes(WORKBOOK_BYTES)
mail_id = ""
try:
mail_id, attachment_id = await _seed_attachment(
file_path,
"赎回申请单.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"redemption",
)
_install_context(("operator",), ("offsite:read",))
response = await _get_preview(attachment_id, disposition="inline")
assert response.status_code == 200
assert response.headers["content-type"] == "application/octet-stream"
assert response.headers["content-disposition"].startswith("attachment")
assert response.content == WORKBOOK_BYTES
finally:
await _cleanup(mail_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_svg_attachment_is_not_inlined(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""SVG 可能携带脚本,必须降级为下载而不是内联渲染。"""
global TRACE_ID
TRACE_ID = f"trace-attachment-{uuid4()}"
root = _use_storage_root(tmp_path, monkeypatch)
file_path = root / "A01.svg"
file_path.write_bytes(SVG_BYTES)
mail_id = ""
try:
mail_id, attachment_id = await _seed_attachment(
file_path, "申购申请单.svg", "image/svg+xml"
)
_install_context(("operator",), ("offsite:read",))
response = await _get_preview(attachment_id, disposition="inline")
assert response.status_code == 200
assert response.headers["content-type"] == "application/octet-stream"
assert response.headers["content-disposition"].startswith("attachment")
finally:
await _cleanup(mail_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_unknown_attachment_returns_not_found(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
global TRACE_ID
TRACE_ID = f"trace-attachment-{uuid4()}"
_use_storage_root(tmp_path, monkeypatch)
try:
_install_context(("operator",), ("offsite:read",))
response = await _get_preview("M000000000000-A01")
assert response.status_code == 200
assert response.json()["code"] == 404
finally:
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_file_outside_storage_root_is_rejected(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""数据库路径指向存储根之外时必须拒绝,阻断路径穿越。"""
global TRACE_ID
TRACE_ID = f"trace-attachment-{uuid4()}"
_use_storage_root(tmp_path, monkeypatch)
outside = tmp_path / "outside.pdf"
outside.write_bytes(PDF_BYTES)
mail_id = ""
try:
mail_id, attachment_id = await _seed_attachment(
outside, "申购申请单.pdf", "application/pdf"
)
_install_context(("operator",), ("offsite:read",))
response = await _get_preview(attachment_id)
assert response.status_code == 200
assert response.json()["code"] == 404
assert PDF_BYTES not in response.content
finally:
await _cleanup(mail_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_missing_file_on_disk_returns_not_found(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
global TRACE_ID
TRACE_ID = f"trace-attachment-{uuid4()}"
root = _use_storage_root(tmp_path, monkeypatch)
mail_id = ""
try:
mail_id, attachment_id = await _seed_attachment(
root / "A01_missing.pdf", "申购申请单.pdf", "application/pdf"
)
_install_context(("operator",), ("offsite:read",))
response = await _get_preview(attachment_id)
assert response.status_code == 200
assert response.json()["code"] == 404
finally:
await _cleanup(mail_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_preview_requires_offsite_permission(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
global TRACE_ID
TRACE_ID = f"trace-attachment-{uuid4()}"
root = _use_storage_root(tmp_path, monkeypatch)
file_path = root / "A01.pdf"
file_path.write_bytes(PDF_BYTES)
mail_id = ""
try:
mail_id, attachment_id = await _seed_attachment(
file_path, "申购申请单.pdf", "application/pdf"
)
_install_context(("operator",), ())
missing_permission = await _get_preview(attachment_id)
_install_context(("customer",), ("offsite:read",))
wrong_role = await _get_preview(attachment_id)
assert missing_permission.json()["code"] == 403
assert wrong_role.json()["code"] == 403
finally:
await _cleanup(mail_id)
app.dependency_overrides.clear()
TRACE_ID = ""