295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""`query_customer_profile` 工具 + 画像字段投影的单元测试。
|
||
|
||
覆盖四条要求,两个方向都要有牙:
|
||
|
||
1. **字段策略**:只投影白名单字段,**绝不返回 PII**
|
||
(`real_name`/`birth_date`/`mobile_masked`);且白名单是**白名单**——
|
||
快照里新增未知字段不会自动外泄。
|
||
2. **测评有效期实时判定**:`assessment_expired` 按**当前时间**重算,不信快照里的布尔值。
|
||
3. **失败关闭**:无当前画像 / 快照非法 → **抛错**,不返回 `{}`
|
||
(空画像会被下游误读成"该客户无偏好")。
|
||
4. **越范围不泄露**:他人客户一律按"不存在"处理,且拒绝时不得暴露目标客户是否存在。
|
||
"""
|
||
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.core.errors import ForbiddenAgentError, GenericResourceNotFoundError
|
||
from app.core.profile_projection import project_profile
|
||
from app.service.customer_profile_service import (
|
||
ALLOWED_FIELDS,
|
||
AUDIT_ACTION,
|
||
CustomerProfileQuery,
|
||
CustomerProfileService,
|
||
query_customer_profile_tool,
|
||
)
|
||
|
||
NOW = datetime(2026, 9, 10, 12, 0, tzinfo=UTC)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 替身
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class FakeSession:
|
||
"""只实现本服务用到的两件事:读快照与写审计。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.added: list[Any] = []
|
||
|
||
async def __aenter__(self) -> "FakeSession":
|
||
return self
|
||
|
||
async def __aexit__(self, *args: object) -> None:
|
||
return None
|
||
|
||
def begin(self) -> "FakeSession":
|
||
return self
|
||
|
||
def add(self, item: Any) -> None:
|
||
self.added.append(item)
|
||
|
||
|
||
def context(
|
||
user_id: str = "9101",
|
||
*,
|
||
scopes: dict[str, str] | None = None,
|
||
customer_ids: tuple[str, ...] = (),
|
||
permissions: tuple[str, ...] = ("memory:read:self", "memory:read:customer"),
|
||
) -> RequestContext:
|
||
return RequestContext(
|
||
user_id=user_id,
|
||
trace_id="trace-1",
|
||
roles=("customer",),
|
||
permissions=permissions,
|
||
permission_scopes=scopes or {"memory:read:self": "self", "memory:read:customer": "self"},
|
||
customer_ids=customer_ids,
|
||
)
|
||
|
||
|
||
def snapshot(**overrides: Any) -> dict[str, Any]:
|
||
base: dict[str, Any] = {
|
||
"investor_type": "C5",
|
||
"investment_horizon": "long_term",
|
||
"trading_frequency": "high",
|
||
"preferred_asset_class": ["equity_fund"],
|
||
"risk_tags": ["aggressive"],
|
||
"customer_tier": "diamond",
|
||
"behavior_score": 88,
|
||
"total_asset": "12800000.00",
|
||
"assessment_valid_until": (NOW + timedelta(days=100)).isoformat(),
|
||
"assessment_expired": False,
|
||
}
|
||
base.update(overrides)
|
||
return base
|
||
|
||
|
||
def wire(monkeypatch: pytest.MonkeyPatch, rows: list[dict[str, Any]]) -> FakeSession:
|
||
"""把 `PlatformRepository.rows` 换成返回给定行的替身。"""
|
||
session = FakeSession()
|
||
|
||
class FakeRepo:
|
||
def __init__(self, _session: Any) -> None:
|
||
pass
|
||
|
||
async def rows(self, *_a: Any, **_kw: Any) -> list[dict[str, Any]]:
|
||
return rows
|
||
|
||
monkeypatch.setattr("app.service.customer_profile_service.PlatformRepository", FakeRepo)
|
||
monkeypatch.setattr(
|
||
"app.service.customer_profile_service.SessionFactory", lambda: session
|
||
)
|
||
return session
|
||
|
||
|
||
def row(payload: dict[str, Any] | None = None, version: int = 1) -> dict[str, Any]:
|
||
return {"snapshot": payload if payload is not None else snapshot(), "version": version}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. 字段策略
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_projection_only_exposes_whitelisted_fields() -> None:
|
||
"""白名单:快照里混入 PII 与未知字段都不得外泄。"""
|
||
dirty = snapshot(
|
||
real_name="陈宏远",
|
||
birth_date="1972-06-15",
|
||
mobile_masked="138****0101",
|
||
trade_account="TA91010001",
|
||
some_future_field="会外泄吗",
|
||
)
|
||
projected = project_profile(dirty, now=NOW)
|
||
|
||
assert set(projected) <= ALLOWED_FIELDS, "投影结果出现了白名单外的字段"
|
||
leaks = ("real_name", "birth_date", "mobile_masked", "trade_account", "some_future_field")
|
||
for leaked in leaks:
|
||
assert leaked not in projected, f"{leaked} 不该外泄"
|
||
|
||
|
||
def test_projection_returns_empty_for_malformed_snapshot() -> None:
|
||
assert project_profile(None, now=NOW) == {}
|
||
assert project_profile([], now=NOW) == {}
|
||
assert project_profile("not-a-dict", now=NOW) == {}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. 测评有效期实时判定
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_assessment_expired_is_recomputed_not_trusted() -> None:
|
||
"""**反向断言**:快照谎报 `assessment_expired=False`,但有效期已过 → 必须判为过期。"""
|
||
lying = snapshot(
|
||
assessment_valid_until=(NOW - timedelta(days=1)).isoformat(),
|
||
assessment_expired=False, # ← 快照里的值是错的
|
||
)
|
||
projected = project_profile(lying, now=NOW)
|
||
assert projected["assessment_expired"] is True, "不能信快照里的布尔值"
|
||
|
||
|
||
def test_assessment_valid_until_just_now_counts_as_expired() -> None:
|
||
"""边界:有效期正好等于当前时间视为**已过期**(与 SuitabilityService 口径一致)。"""
|
||
projected = project_profile(
|
||
snapshot(assessment_valid_until=NOW.isoformat()), now=NOW
|
||
)
|
||
assert projected["assessment_expired"] is True
|
||
|
||
|
||
def test_unparseable_valid_until_keeps_field_but_does_not_claim_expired() -> None:
|
||
"""有效期无法解析时不臆造"过期"判定(也不删除原始值)。"""
|
||
projected = project_profile(snapshot(assessment_valid_until="昨天"), now=NOW)
|
||
assert projected["assessment_valid_until"] == "昨天"
|
||
assert "assessment_expired" not in projected or projected["assessment_expired"] is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. 失败关闭
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_missing_snapshot_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""无当前画像必须**抛错**,不能返回空画像。"""
|
||
wire(monkeypatch, [])
|
||
with pytest.raises(GenericResourceNotFoundError, match="暂无当前画像"):
|
||
await query_customer_profile_tool(CustomerProfileQuery(customer_id="9101"), context())
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_snapshot_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""快照存在但内容非法(投影为空)同样失败关闭。"""
|
||
wire(monkeypatch, [row(payload={})])
|
||
with pytest.raises(GenericResourceNotFoundError):
|
||
await query_customer_profile_tool(CustomerProfileQuery(customer_id="9101"), context())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. 鉴权与数据范围
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_customer_can_read_own_profile(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
session = wire(monkeypatch, [row()])
|
||
out = await query_customer_profile_tool(
|
||
CustomerProfileQuery(customer_id="9101"), context("9101")
|
||
)
|
||
assert out["profile"]["investor_type"] == "C5"
|
||
assert out["customer_id"] == "9101"
|
||
# 审计与读取同事务落库
|
||
assert [a.action_type for a in session.added] == [AUDIT_ACTION]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reading_another_customer_without_scope_is_denied_as_not_found(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""越范围一律按"不存在"处理,不泄露目标客户是否存在。"""
|
||
wire(monkeypatch, [row()])
|
||
with pytest.raises(GenericResourceNotFoundError, match="客户不可访问"):
|
||
await query_customer_profile_tool(
|
||
CustomerProfileQuery(customer_id="9103"), context("9101")
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_own_customers_scope_allows_assigned_customer(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""`own_customers` 且客户确实归属调用者时才放行。"""
|
||
wire(monkeypatch, [row()])
|
||
scopes = {"memory:read:self": "self", "memory:read:customer": "own_customers"}
|
||
out = await query_customer_profile_tool(
|
||
CustomerProfileQuery(customer_id="9103"),
|
||
context("9002", scopes=scopes, customer_ids=("9103",)),
|
||
)
|
||
assert out["customer_id"] == "9103"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_all_scope_allows_any_customer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
wire(monkeypatch, [row()])
|
||
out = await query_customer_profile_tool(
|
||
CustomerProfileQuery(customer_id="9999"),
|
||
context("9003", scopes={"memory:read:self": "self", "memory:read:customer": "all"}),
|
||
)
|
||
assert out["customer_id"] == "9999"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_missing_permission_is_denied(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""连权限都没有:直接 403 语义(`ForbiddenAgentError`),并且会写权限拒绝审计。"""
|
||
wire(monkeypatch, [row()])
|
||
with pytest.raises(ForbiddenAgentError):
|
||
await query_customer_profile_tool(
|
||
CustomerProfileQuery(customer_id="9101"), context("9101", permissions=())
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. 字段筛选
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_requested_fields_filter_the_projection(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
wire(monkeypatch, [row()])
|
||
out = await query_customer_profile_tool(
|
||
CustomerProfileQuery(customer_id="9101", fields=("investor_type", "assessment_expired")),
|
||
context("9101"),
|
||
)
|
||
assert set(out["profile"]) == {"investor_type", "assessment_expired"}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_requesting_a_pii_field_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Agent 不能通过 `fields` 参数把 PII 要出来。"""
|
||
wire(monkeypatch, [row()])
|
||
with pytest.raises(ForbiddenAgentError, match="不支持查询画像字段"):
|
||
await query_customer_profile_tool(
|
||
CustomerProfileQuery(customer_id="9101", fields=("real_name",)), context("9101")
|
||
)
|
||
|
||
|
||
def test_query_model_rejects_non_numeric_customer_id() -> None:
|
||
from pydantic import ValidationError
|
||
|
||
with pytest.raises(ValidationError):
|
||
CustomerProfileQuery(customer_id="abc")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_service_is_injectable_for_tests(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""`session_factory` 可注入(单测不连库)。"""
|
||
wire(monkeypatch, [row(version=3)])
|
||
view = await CustomerProfileService().load(
|
||
CustomerProfileQuery(customer_id="9101"), context("9101"), now=NOW
|
||
)
|
||
assert view.version == "3"
|