182 lines
6.4 KiB
Python
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())
|