Files
group_fqcd_jr/tests/unit/service/test_knowledge_reference.py
T
lzf_0626 6516ccb385 feat: 第二版——接口契约对齐 docs/05,修复静默故障与数据库基线
相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。

一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
   approve→reviews(需 body decision)、activate→activations、
   rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
   UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
   RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
   输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
   {data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
   不再返回 FastAPI 默认的 {"detail": ...}。

二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。

三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
  顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
  但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
  召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
  .env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。

四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。

五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。

验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
2026-09-10 15:55:54 +08:00

182 lines
6.4 KiB
Python

"""K001 知识引用解析(B1)单元测试:签名、过期、跨用户、发布状态、脱敏。"""
import json
from datetime import UTC, date, datetime, timedelta
from typing import Any
import pytest
from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError, RecoverableAgentError, ResourceNotFoundError
from app.service.authorization_service import AuthorizationService
from app.service.knowledge_service import (
SIGNING_SECRET_ENV,
KnowledgeReferenceService,
build_reference_token,
)
SECRET = "unit-test-signing-secret"
PERMISSION = "knowledge:reference:read"
class FakeResult:
"""最小 SQLAlchemy Result 替身,只提供 mappings().first()。"""
def __init__(self, row: dict[str, Any] | None) -> None:
self._row = row
def mappings(self) -> "FakeResult":
return self
def first(self) -> dict[str, Any] | None:
return self._row
class FakeSession:
def __init__(self, row: dict[str, Any] | None) -> None:
self._row = row
async def __aenter__(self) -> "FakeSession":
return self
async def __aexit__(self, *args: object) -> None:
return None
async def execute(self, *args: object, **kwargs: object) -> FakeResult:
return FakeResult(self._row)
def metadata_row(**overrides: Any) -> dict[str, Any]:
row: dict[str, Any] = {
"id": 42,
"knowledge_type": "faq",
"title": "场内基金申购费率说明",
"version": "v2",
"milvus_collection": "fin_faq_collection",
"tags": json.dumps(["费率", "申购"]),
"effective_date": date(2026, 1, 1),
"expire_date": date(2030, 1, 1),
"review_status": "published",
"status": "active",
"content_text": "内部正文:不得通过引用接口返回的整份文档内容。",
}
row.update(overrides)
return row
def service_with_row(row: dict[str, Any] | None) -> KnowledgeReferenceService:
return KnowledgeReferenceService(session_factory=lambda: FakeSession(row))
def context(**overrides: Any) -> RequestContext:
values: dict[str, Any] = {
"user_id": "7",
"trace_id": "trace-knowledge",
"permissions": (PERMISSION,),
}
values.update(overrides)
return RequestContext(**values)
def token_for(user_id: str = "7", knowledge_id: int = 42, **kwargs: Any) -> str:
expires_at = kwargs.pop("expires_at", datetime.now(UTC) + timedelta(minutes=30))
return build_reference_token(
user_id=user_id, knowledge_id=knowledge_id, expires_at=expires_at, secret=SECRET, **kwargs
)
@pytest.fixture(autouse=True)
def signing_secret(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(SIGNING_SECRET_ENV, SECRET)
async def test_valid_token_resolves_masked_metadata() -> None:
result = await service_with_row(metadata_row()).resolve(context(), token_for())
assert result["knowledge_id"] == 42
assert result["title"] == "场内基金申购费率说明"
assert result["collection"] == "fin_faq_collection"
assert result["tags"] == ["费率", "申购"]
assert result["effective_date"] == "2026-01-01"
assert result["expire_date"] == "2030-01-01"
assert result["content_included"] is False
assert "content_text" not in result
async def test_full_document_text_is_never_returned() -> None:
result = await service_with_row(metadata_row()).resolve(context(), token_for())
assert "内部正文" not in json.dumps(result, ensure_ascii=False)
@pytest.mark.parametrize(
"token_kind",
["wrong_signature", "expired", "cross_user", "malformed", "not_a_number"],
)
async def test_invalid_tokens_are_rejected(token_kind: str) -> None:
valid = token_for()
tokens = {
"wrong_signature": valid[:-1] + ("0" if valid[-1] != "0" else "1"),
"expired": token_for(expires_at=datetime.now(UTC) - timedelta(seconds=1)),
"cross_user": token_for(user_id="8"),
"malformed": "kr1.7.42.abc.zzzz",
"not_a_number": "kr1.7.not-a-number.9999999999." + "a" * 64,
}
with pytest.raises(ResourceNotFoundError):
await service_with_row(metadata_row()).resolve(context(), tokens[token_kind])
async def test_token_without_prefix_is_rejected() -> None:
token = token_for().replace("kr1.", "kr2.", 1)
with pytest.raises(ResourceNotFoundError):
await service_with_row(metadata_row()).resolve(context(), token)
@pytest.mark.parametrize(
"overrides",
[
{"review_status": "pending"},
{"review_status": "approved"},
{"review_status": "archived"},
{"status": "inactive"},
{"status": "deleted"},
{"expire_date": date(2020, 1, 1)},
{"effective_date": date(2099, 1, 1)},
],
)
async def test_unpublished_or_invalid_knowledge_is_rejected(overrides: dict[str, Any]) -> None:
service = service_with_row(metadata_row(**overrides))
with pytest.raises(ResourceNotFoundError):
await service.resolve(context(), token_for())
async def test_unknown_knowledge_id_is_rejected() -> None:
with pytest.raises(ResourceNotFoundError) as excinfo:
await service_with_row(None).resolve(context(), token_for())
# 文档 §3.6 未给通用 404 单独定义码,实现统一复用 SESSION_NOT_FOUND。
assert excinfo.value.code == "SESSION_NOT_FOUND"
assert excinfo.value.status_code == 404
async def test_knowledge_without_expire_date_is_allowed() -> None:
service = service_with_row(metadata_row(expire_date=None, version=None, tags=None))
result = await service.resolve(context(), token_for())
assert result["expire_date"] is None
assert result["version"] is None
assert result["tags"] == []
async def test_missing_signing_secret_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(SIGNING_SECRET_ENV, raising=False)
with pytest.raises(RecoverableAgentError, match=SIGNING_SECRET_ENV):
await service_with_row(metadata_row()).resolve(context(), token_for())
async def test_permission_is_checked_before_token_validation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def deny(_context: RequestContext, _permission: str, *, admin: bool = False) -> None:
raise ForbiddenAgentError("缺少操作权限")
monkeypatch.setattr(AuthorizationService, "require", deny)
with pytest.raises(ForbiddenAgentError):
await service_with_row(metadata_row()).resolve(context(), token_for())