318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""b5-1 task-detail persistence and API contract tests."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.testclient import TestClient
|
||
from pydantic import ValidationError
|
||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||
from starlette.requests import Request
|
||
|
||
from app.gateway import auth_middleware, csrf_middleware
|
||
from app.gateway.routers import task_deeplink_details as router
|
||
from deerflow.persistence.task_deeplink_details import (
|
||
MemoryTaskDeeplinkDetailStore,
|
||
TaskDeeplinkDetailRepository,
|
||
)
|
||
from deerflow.persistence.task_deeplink_details.model import TaskDeeplinkDetailRow
|
||
|
||
|
||
def _request(store, *, role: str = "admin", external: bool = False, anonymous: bool = False):
|
||
state = SimpleNamespace(
|
||
user=None
|
||
if anonymous
|
||
else SimpleNamespace(id="admin-1", system_role=role)
|
||
)
|
||
if external:
|
||
state.task_deeplink_external_writer = True
|
||
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(task_deeplink_detail_store=store)), state=state)
|
||
|
||
|
||
def _http_request(method: str, path: str, headers: dict[str, str] | None = None) -> Request:
|
||
return Request(
|
||
{
|
||
"type": "http",
|
||
"http_version": "1.1",
|
||
"method": method,
|
||
"scheme": "http",
|
||
"path": path,
|
||
"query_string": b"",
|
||
"headers": [
|
||
(key.lower().encode("latin-1"), value.encode("latin-1"))
|
||
for key, value in (headers or {}).items()
|
||
],
|
||
"client": ("test", 1234),
|
||
"server": ("test", 80),
|
||
}
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_memory_store_skips_identical_snapshot_writes_and_versions_changes() -> None:
|
||
store = MemoryTaskDeeplinkDetailStore()
|
||
first, created, changed = await store.upsert_detail(
|
||
"b5-1",
|
||
"task-1",
|
||
task_name="任务甲",
|
||
task_content="第一版详情",
|
||
overview="概述",
|
||
extra={"source": "external"},
|
||
updated_by="tester",
|
||
)
|
||
assert created is True
|
||
assert changed is True
|
||
assert first["version"] == 1
|
||
|
||
second, created, changed = await store.upsert_detail(
|
||
"b5-1",
|
||
"task-1",
|
||
task_name="任务甲",
|
||
task_content="第二版详情",
|
||
overview="",
|
||
extra={"priority": 3},
|
||
updated_by="tester-2",
|
||
)
|
||
assert created is False
|
||
assert changed is True
|
||
assert second["version"] == 2
|
||
assert second["extra"] == {"priority": 3}
|
||
|
||
same, created, changed = await store.upsert_detail(
|
||
"b5-1",
|
||
"task-1",
|
||
task_name="任务甲",
|
||
task_content="第二版详情",
|
||
overview="",
|
||
extra={"priority": 3},
|
||
updated_by="retry-writer",
|
||
)
|
||
assert created is False
|
||
assert changed is False
|
||
assert same["version"] == 2
|
||
assert same["updated_at"] == second["updated_at"]
|
||
assert await store.get_detail("b5-1", "task-2") is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_memory_store_partitions_same_id_by_sub_type() -> None:
|
||
store = MemoryTaskDeeplinkDetailStore()
|
||
_, created_a, _ = await store.upsert_detail(
|
||
"b5-1",
|
||
"task-9",
|
||
sub_type="yuqing",
|
||
task_name="舆情任务",
|
||
task_content="舆情详情",
|
||
overview="",
|
||
extra={},
|
||
updated_by="writer",
|
||
)
|
||
_, created_b, _ = await store.upsert_detail(
|
||
"b5-1",
|
||
"task-9",
|
||
sub_type="other",
|
||
task_name="其它任务",
|
||
task_content="其它详情",
|
||
overview="",
|
||
extra={},
|
||
updated_by="writer",
|
||
)
|
||
assert created_a is True and created_b is True
|
||
|
||
yuqing = await store.get_detail("b5-1", "task-9", sub_type="yuqing")
|
||
other = await store.get_detail("b5-1", "task-9", sub_type="other")
|
||
untyped = await store.get_detail("b5-1", "task-9")
|
||
assert yuqing is not None and yuqing["task_name"] == "舆情任务" and yuqing["sub_type"] == "yuqing"
|
||
assert other is not None and other["task_name"] == "其它任务"
|
||
assert untyped is None
|
||
|
||
# 同类型重试幂等;跨类型互不覆盖。
|
||
_, created, changed = await store.upsert_detail(
|
||
"b5-1",
|
||
"task-9",
|
||
sub_type="yuqing",
|
||
task_name="舆情任务",
|
||
task_content="舆情详情",
|
||
overview="",
|
||
extra={},
|
||
updated_by="retry",
|
||
)
|
||
assert (created, changed) == (False, False)
|
||
assert (await store.get_detail("b5-1", "task-9", sub_type="other"))["version"] == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_sql_store_round_trips_detail_snapshot() -> None:
|
||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||
try:
|
||
async with engine.begin() as connection:
|
||
await connection.run_sync(TaskDeeplinkDetailRow.__table__.create)
|
||
store = TaskDeeplinkDetailRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||
saved, created, changed = await store.upsert_detail(
|
||
"b5-1",
|
||
"task-42",
|
||
task_name="SQL 任务",
|
||
task_content="详情",
|
||
overview="概述",
|
||
extra={"tags": ["a", "b"]},
|
||
updated_by="admin",
|
||
)
|
||
assert created is True
|
||
assert changed is True
|
||
assert saved["detail_id"] == "task-42"
|
||
assert (await store.get_detail("b5-1", "task-42"))["extra"] == {"tags": ["a", "b"]}
|
||
|
||
same, created, changed = await store.upsert_detail(
|
||
"b5-1",
|
||
"task-42",
|
||
task_name="SQL 任务",
|
||
task_content="详情",
|
||
overview="概述",
|
||
extra={"tags": ["a", "b"]},
|
||
updated_by="retry-writer",
|
||
)
|
||
assert created is False
|
||
assert changed is False
|
||
assert same["version"] == 1
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_router_returns_camel_case_contract_and_allows_open_writes() -> None:
|
||
store = MemoryTaskDeeplinkDetailStore()
|
||
request = _request(store)
|
||
body = router.TaskDeeplinkDetailUpsertRequest.model_validate(
|
||
{
|
||
"taskName": "任务分析名称",
|
||
"taskContent": "这是任务详情",
|
||
"overview": "任务概述",
|
||
"extra": {"origin": "b5", "severity": 2},
|
||
}
|
||
)
|
||
saved = await router.upsert_task_deeplink_detail(request, body, "b5-1", "id-7")
|
||
assert saved.created is True
|
||
assert saved.changed is True
|
||
assert saved.data.task_name == "任务分析名称"
|
||
assert saved.data.extra == {"origin": "b5", "severity": 2}
|
||
|
||
fetched = await router.get_task_deeplink_detail(request, "b5-1", "id-7", type="")
|
||
assert fetched.data.detail_id == "id-7"
|
||
assert fetched.data.model_dump(by_alias=True)["taskName"] == "任务分析名称"
|
||
assert fetched.data.model_dump(by_alias=True)["type"] == "b5-1"
|
||
assert fetched.data.model_dump(by_alias=True)["taskType"] == ""
|
||
|
||
retry = await router.upsert_task_deeplink_detail(request, body, "b5-1", "id-7")
|
||
assert retry.created is False
|
||
assert retry.changed is False
|
||
assert retry.data.version == 1
|
||
|
||
# 写入端点对所有人开放:普通用户与未登录调用方都可直接写。
|
||
by_user = await router.upsert_task_deeplink_detail(
|
||
_request(store, role="user"), body, "b5-1", "id-8"
|
||
)
|
||
assert by_user.created is True
|
||
|
||
by_anonymous = await router.upsert_task_deeplink_detail(
|
||
_request(store, anonymous=True), body, "b5-1", "id-anonymous"
|
||
)
|
||
assert by_anonymous.created is True
|
||
stored = await store.get_detail("b5-1", "id-anonymous")
|
||
assert stored is not None and stored["updated_by"] == "external:task-deeplink"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_router_partitions_same_id_by_request_type() -> None:
|
||
store = MemoryTaskDeeplinkDetailStore()
|
||
request = _request(store)
|
||
|
||
typed_body = router.TaskDeeplinkDetailUpsertRequest.model_validate(
|
||
{"taskName": "舆情任务", "taskContent": "舆情详情", "type": "yuqing"}
|
||
)
|
||
saved = await router.upsert_task_deeplink_detail(request, typed_body, "b5-1", "task-x")
|
||
assert saved.created is True
|
||
assert saved.data.detail_sub_type == "yuqing"
|
||
assert saved.data.model_dump(by_alias=True)["taskType"] == "yuqing"
|
||
|
||
other_body = router.TaskDeeplinkDetailUpsertRequest.model_validate(
|
||
{"taskName": "行动任务", "taskContent": "行动详情", "type": "action"}
|
||
)
|
||
await router.upsert_task_deeplink_detail(request, other_body, "b5-1", "task-x")
|
||
|
||
fetched = await router.get_task_deeplink_detail(request, "b5-1", "task-x", type="yuqing")
|
||
assert fetched.data.task_name == "舆情任务"
|
||
fetched_other = await router.get_task_deeplink_detail(request, "b5-1", "task-x", type="action")
|
||
assert fetched_other.data.task_name == "行动任务"
|
||
|
||
# 该 id 未写无类型分区:不带 ?type= 读不到(不会回落到任意类型)。
|
||
with pytest.raises(HTTPException) as error:
|
||
await router.get_task_deeplink_detail(request, "b5-1", "task-x", type="")
|
||
assert error.value.status_code == 404
|
||
|
||
|
||
def test_router_rejects_blank_required_fields_and_non_object_extra() -> None:
|
||
with pytest.raises(ValidationError):
|
||
router.TaskDeeplinkDetailUpsertRequest.model_validate(
|
||
{"taskName": " ", "taskContent": "详情"}
|
||
)
|
||
with pytest.raises(ValidationError):
|
||
router.TaskDeeplinkDetailUpsertRequest.model_validate(
|
||
{"taskName": "名称", "taskContent": "详情", "extra": ["not", "an", "object"]}
|
||
)
|
||
|
||
|
||
def test_b5_put_is_credential_free_and_bypasses_csrf() -> None:
|
||
# 无 key、无登录态:PUT 直接放行并打上外部写入标记(供审计)。
|
||
bare = _http_request("PUT", "/api/task-deeplink-details/b5-1/id-9")
|
||
assert auth_middleware._is_task_deeplink_external_write(bare, bare.url.path)
|
||
assert auth_middleware._authorize_task_deeplink_external_write(bare) is None
|
||
assert bare.state.task_deeplink_external_writer is True
|
||
assert csrf_middleware.should_check_csrf(bare) is False
|
||
|
||
# 旧集成方带的 X-Integration-Key 不再校验(只记指纹),错误 key 也放行。
|
||
stale_key = _http_request(
|
||
"PUT",
|
||
"/api/task-deeplink-details/b5-1/id-9",
|
||
{"X-Integration-Key": "anything"},
|
||
)
|
||
assert auth_middleware._authorize_task_deeplink_external_write(stale_key) is None
|
||
assert stale_key.state.task_deeplink_external_key_fingerprint
|
||
|
||
# 通道只覆盖 PUT;其它方法仍走正常登录校验。
|
||
post_request = _http_request("POST", "/api/task-deeplink-details/b5-1/id-9")
|
||
assert not auth_middleware._is_task_deeplink_external_write(post_request, post_request.url.path)
|
||
assert csrf_middleware.should_check_csrf(post_request) is True
|
||
|
||
|
||
def test_unauthenticated_put_succeeds_but_read_still_requires_login() -> None:
|
||
app = FastAPI()
|
||
app.state.task_deeplink_detail_store = MemoryTaskDeeplinkDetailStore()
|
||
app.include_router(router.router)
|
||
app.add_middleware(auth_middleware.AuthMiddleware)
|
||
|
||
client = TestClient(app)
|
||
written = client.put(
|
||
"/api/task-deeplink-details/b5-1/task-open",
|
||
json={"taskName": "开放写入任务", "taskContent": "这是任务详情"},
|
||
)
|
||
assert written.status_code == 200
|
||
assert written.json()["created"] is True
|
||
|
||
# 同一 id 再写一个带类型的快照,互不覆盖(端到端)。
|
||
typed = client.put(
|
||
"/api/task-deeplink-details/b5-1/task-open",
|
||
json={"taskName": "同id不同类型", "taskContent": "类型详情", "type": "yuqing"},
|
||
)
|
||
assert typed.status_code == 200
|
||
assert typed.json()["data"]["taskType"] == "yuqing"
|
||
assert typed.json()["created"] is True
|
||
|
||
# 读取仍走正常登录校验:未登录 GET(无论带不带 type)→ 401,
|
||
# 不因写入开放而连带放开。
|
||
denied = client.get("/api/task-deeplink-details/b5-1/task-open")
|
||
assert denied.status_code == 401
|
||
denied_typed = client.get("/api/task-deeplink-details/b5-1/task-open?type=yuqing")
|
||
assert denied_typed.status_code == 401
|