deer-flow/backend/tests/test_scheduled_task_router_behavior.py
Vanzeren 095092418c
fix(gateway):unify thread id validation (#4589)
* fix(gateway): unify thread ID validation at the API boundary

Thread ID entry points accepted arbitrary strings while downstream
consumers (filesystem paths, Kubernetes Provisioner, JSONL event store)
each enforced different character restrictions, so invalid IDs were
persisted first and only failed later during sandbox/workspace init.

Centralize validation in deerflow.utils.thread_id (pattern
^[A-Za-z0-9_-]{1,64}$): validate at routers, RunCreateRequest,
scheduler dispatch, paths.py, JSONL store, embedded client, and align
the Provisioner pattern (pinned by a parity test). UUIDs are still
generated only when no ID is supplied; caller-supplied opaque IDs stay
supported.

Deliberate exceptions: DELETE /threads/{id} keeps str as the legacy
cleanup escape hatch (filesystem cleanup guarded), read-only
client.get_thread stays unvalidated, and scheduler rows with legacy
invalid IDs record a failed dispatch instead of raising out of the
poll loop.

* docs: document canonical thread ID contract

README: caller-supplied thread IDs need not be UUIDs; the canonical
pattern and per-endpoint behavior. AGENTS.md: the shared
deerflow.utils.thread_id contract, its enforcement boundaries, and the
legacy-ID escape hatches.

* fix(gateway): close thread ID validation gaps at remaining entry points

Follow-up to the canonical thread ID contract: a full audit found the
uniform-422 coverage only reached about half of the thread_id surfaces.

- routers: 18 routes still took a bare thread_id: str — 13 in
  thread_runs.py (including the five messages/events/workspace-changes
  reads that returned 500 on the JSONL event store vs 404/empty on the
  DB store), 4 read routes in threads.py, and the suggestions route
  flagged in review. DELETE /api/threads/{id} keeps str as the declared
  legacy-cleanup escape hatch.
- client: upload_files/delete_upload/list_uploads/get_artifact now
  validate up front, fulfilling the RFC's 'all mutating entry points'
  clause (get_thread stays unvalidated as the declared legacy read path).
- tui: the /resume literal-ref fallback validates against the canonical
  contract and reports a descriptive error instead of failing deep in
  the client.
- scripts/support_bundle.py: replace the drifted dot-allowing pattern
  with a byte-identical copy of THREAD_ID_PATTERN (kept local so the
  script still runs with a broken venv).

* test(gateway): guard the canonical thread ID contract against regressions

- test_thread_id_route_contract.py: static AST sweep asserting every
  route handler with a thread_id parameter annotates ThreadId
  (whitelist: the DELETE escape hatch), plus a runtime sweep hitting
  all 44 thread_id routes with a non-canonical ID and asserting a 422
  that names thread_id, plus a websocket upgrade-rejection case.
- test_thread_id_validation.py: client entry-point validation,
  support_bundle pattern parity, and TUI literal-ref fallback tests.
- Align two tests that encoded the old contract (dotted IDs).
2026-08-01 19:42:44 +08:00

678 lines
23 KiB
Python

from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.gateway.routers import scheduled_tasks
@pytest.mark.parametrize(
"model",
[
scheduled_tasks.ScheduledTaskCreateRequest,
scheduled_tasks.ScheduledTaskUpdateRequest,
],
)
@pytest.mark.parametrize("thread_id", ["", "thread.with.dot", "../escape", "x" * 65])
def test_scheduled_task_models_reject_invalid_thread_ids(model, thread_id):
from pydantic import ValidationError
kwargs = {"thread_id": thread_id}
if model is scheduled_tasks.ScheduledTaskCreateRequest:
kwargs.update(
title="Task",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 * * * *"},
timezone="UTC",
)
with pytest.raises(ValidationError):
model(**kwargs)
class _Repo:
def __init__(self) -> None:
self.created = []
self.items = {}
async def list_by_user(self, user_id: str):
return [item for item in self.items.values() if item["user_id"] == user_id]
async def list_by_user_and_thread(self, user_id: str, thread_id: str):
return [item for item in self.items.values() if item["user_id"] == user_id and item["thread_id"] == thread_id]
async def create(self, **kwargs):
item = {
"id": kwargs["task_id"],
"user_id": kwargs["user_id"],
"thread_id": kwargs["thread_id"],
"context_mode": kwargs["context_mode"],
"title": kwargs["title"],
"prompt": kwargs["prompt"],
"schedule_type": kwargs["schedule_type"],
"schedule_spec": kwargs["schedule_spec"],
"timezone": kwargs["timezone"],
"status": "enabled",
"next_run_at": kwargs["next_run_at"],
}
self.items[item["id"]] = item
self.created.append(item)
return item
async def get(self, task_id: str, *, user_id: str):
item = self.items.get(task_id)
if item is None or item["user_id"] != user_id:
return None
return item
async def update(self, task_id: str, *, user_id: str, updates):
item = await self.get(task_id, user_id=user_id)
if item is None:
return None
item.update(updates)
return item
async def delete(self, task_id: str, *, user_id: str):
item = await self.get(task_id, user_id=user_id)
if item is None:
return False
self.items.pop(task_id, None)
return True
async def list_by_task(self, task_id: str):
return []
class _Service:
def __init__(self) -> None:
self.calls = []
self.result = {"outcome": "launched"}
async def dispatch_task(self, task, *, now, trigger):
self.calls.append((task, now, trigger))
return self.result
class _RunStore:
def __init__(self, runs):
self.runs = runs
async def get(self, run_id: str, *, user_id: str):
run = self.runs.get(run_id)
if run is None or run.get("user_id") != user_id:
return None
return run
class _Config:
def __init__(self, min_once_delay_seconds: int = 60) -> None:
self.scheduler = SimpleNamespace(min_once_delay_seconds=min_once_delay_seconds)
@pytest.mark.asyncio
async def test_create_scheduled_task_uses_repo():
repo = _Repo()
request = SimpleNamespace()
body = scheduled_tasks.ScheduledTaskCreateRequest(
thread_id="thread-1",
title="Daily summary",
prompt="Summarize thread",
schedule_type="once",
schedule_spec={"run_at": "2027-01-01T01:00:00+00:00"},
timezone="UTC",
)
user = SimpleNamespace(id="user-1")
thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True))
config = _Config()
old_repo = scheduled_tasks.get_scheduled_task_repo
old_thread_store = scheduled_tasks.get_thread_store
old_config = scheduled_tasks.get_config
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_thread_store = lambda _request: thread_store
scheduled_tasks.get_config = lambda: config
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
created = await scheduled_tasks.create_scheduled_task.__wrapped__(
request=request,
body=body,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_thread_store = old_thread_store
scheduled_tasks.get_config = old_config
scheduled_tasks.get_optional_user_from_request = old_user
assert created["title"] == "Daily summary"
assert created["user_id"] == "user-1"
assert created["next_run_at"] == datetime(2027, 1, 1, 1, 0, tzinfo=UTC)
@pytest.mark.asyncio
async def test_create_fresh_thread_task_does_not_require_thread_id():
repo = _Repo()
request = SimpleNamespace()
body = scheduled_tasks.ScheduledTaskCreateRequest(
context_mode="fresh_thread_per_run",
thread_id=None,
title="Fresh task",
prompt="Run in fresh thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
)
user = SimpleNamespace(id="user-1")
thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True))
config = _Config()
old_repo = scheduled_tasks.get_scheduled_task_repo
old_thread_store = scheduled_tasks.get_thread_store
old_config = scheduled_tasks.get_config
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_thread_store = lambda _request: thread_store
scheduled_tasks.get_config = lambda: config
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
created = await scheduled_tasks.create_scheduled_task.__wrapped__(
request=request,
body=body,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_thread_store = old_thread_store
scheduled_tasks.get_config = old_config
scheduled_tasks.get_optional_user_from_request = old_user
assert created["context_mode"] == "fresh_thread_per_run"
assert created["thread_id"] is None
@pytest.mark.asyncio
async def test_trigger_scheduled_task_dispatches_manual_run():
repo = _Repo()
service = _Service()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_service = scheduled_tasks.get_scheduled_task_service
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_scheduled_task_service = lambda _request: service
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
result = await scheduled_tasks.trigger_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_scheduled_task_service = old_service
scheduled_tasks.get_optional_user_from_request = old_user
assert result == {"id": "task-1", "triggered": True}
assert len(service.calls) == 1
assert service.calls[0][2] == "manual"
@pytest.mark.asyncio
async def test_trigger_scheduled_task_returns_conflict_when_dispatch_conflicts():
repo = _Repo()
service = _Service()
service.result = {"outcome": "conflict", "error": "Thread thread-1 already has an active run"}
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_service = scheduled_tasks.get_scheduled_task_service
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_scheduled_task_service = lambda _request: service
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
with pytest.raises(Exception) as exc_info:
await scheduled_tasks.trigger_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_scheduled_task_service = old_service
scheduled_tasks.get_optional_user_from_request = old_user
assert "already has an active run" in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_scheduled_task_writes_repo():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
config = _Config()
thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True))
old_repo = scheduled_tasks.get_scheduled_task_repo
old_thread_store = scheduled_tasks.get_thread_store
old_config = scheduled_tasks.get_config
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_thread_store = lambda _request: thread_store
scheduled_tasks.get_config = lambda: config
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
result = await scheduled_tasks.update_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
body=scheduled_tasks.ScheduledTaskUpdateRequest(title="Updated title"),
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_thread_store = old_thread_store
scheduled_tasks.get_config = old_config
scheduled_tasks.get_optional_user_from_request = old_user
assert result["title"] == "Updated title"
@pytest.mark.asyncio
async def test_delete_scheduled_task_deletes_repo_row():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
result = await scheduled_tasks.delete_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_optional_user_from_request = old_user
assert result == {"id": "task-1", "deleted": True}
assert repo.items == {}
@pytest.mark.asyncio
async def test_pause_and_resume_scheduled_task_update_status():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
paused = await scheduled_tasks.pause_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
paused_status = paused["status"]
resumed = await scheduled_tasks.resume_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_optional_user_from_request = old_user
assert paused_status == "paused"
assert resumed["status"] == "enabled"
@pytest.mark.asyncio
async def test_pause_rejects_running_task():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
task["status"] = "running"
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
with pytest.raises(Exception) as exc_info:
await scheduled_tasks.pause_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_optional_user_from_request = old_user
assert "currently running" in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_rejects_running_task():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
task["status"] = "running"
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
config = _Config()
thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True))
old_repo = scheduled_tasks.get_scheduled_task_repo
old_thread_store = scheduled_tasks.get_thread_store
old_config = scheduled_tasks.get_config
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_thread_store = lambda _request: thread_store
scheduled_tasks.get_config = lambda: config
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
with pytest.raises(Exception) as exc_info:
await scheduled_tasks.update_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
body=scheduled_tasks.ScheduledTaskUpdateRequest(title="Updated title"),
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_thread_store = old_thread_store
scheduled_tasks.get_config = old_config
scheduled_tasks.get_optional_user_from_request = old_user
assert "currently running" in str(exc_info.value)
@pytest.mark.asyncio
async def test_list_thread_scheduled_tasks_filters_by_thread_id():
repo = _Repo()
await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Thread one task",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
await repo.create(
task_id="task-2",
user_id="user-1",
thread_id="thread-2",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Thread two task",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
result = await scheduled_tasks.list_thread_scheduled_tasks.__wrapped__(
thread_id="thread-1",
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_optional_user_from_request = old_user
assert [task["id"] for task in result] == ["task-1"]
@pytest.mark.asyncio
async def test_list_scheduled_task_runs_returns_persisted_rows_without_side_effects():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
context_mode="reuse_thread",
assistant_id="lead_agent",
title="Task",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
run_repo = SimpleNamespace(
list_by_task=AsyncMock(
return_value=[
{
"id": "task-run-1",
"task_id": "task-1",
"thread_id": "thread-1",
"run_id": "run-1",
"status": "running",
"error": None,
}
]
),
)
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_task_repo = scheduled_tasks.get_scheduled_task_repo
old_run_repo = scheduled_tasks.get_scheduled_task_run_repo
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_scheduled_task_run_repo = lambda _request: run_repo
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
result = await scheduled_tasks.list_scheduled_task_runs.__wrapped__(
task_id=task["id"],
request=request,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_task_repo
scheduled_tasks.get_scheduled_task_run_repo = old_run_repo
scheduled_tasks.get_optional_user_from_request = old_user
assert result[0]["status"] == "running"
@pytest.mark.asyncio
async def test_create_once_task_enforces_minimum_delay():
repo = _Repo()
request = SimpleNamespace()
body = scheduled_tasks.ScheduledTaskCreateRequest(
thread_id="thread-1",
title="Soon task",
prompt="Run soon",
schedule_type="once",
schedule_spec={"run_at": (datetime.now(UTC) + timedelta(seconds=30)).isoformat()},
timezone="UTC",
)
user = SimpleNamespace(id="user-1")
thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True))
config = _Config(min_once_delay_seconds=60)
old_repo = scheduled_tasks.get_scheduled_task_repo
old_thread_store = scheduled_tasks.get_thread_store
old_config = scheduled_tasks.get_config
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_thread_store = lambda _request: thread_store
scheduled_tasks.get_config = lambda: config
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
with pytest.raises(Exception) as exc_info:
await scheduled_tasks.create_scheduled_task.__wrapped__(
request=request,
body=body,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_thread_store = old_thread_store
scheduled_tasks.get_config = old_config
scheduled_tasks.get_optional_user_from_request = old_user
assert "once schedule must be at least" in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_terminal_once_task_with_future_run_at_rearms_it():
"""PATCHing a fresh future run_at onto a completed/failed/cancelled once
task must reset status to enabled — claim_due_tasks only admits enabled
rows, so keeping the terminal status returns a next_run_at that never fires."""
repo = _Repo()
task = await repo.create(
task_id="task-terminal",
user_id="user-1",
thread_id=None,
context_mode="fresh_thread_per_run",
assistant_id="lead_agent",
title="Once done",
prompt="p",
schedule_type="once",
schedule_spec={"run_at": "2026-07-01T00:00:00+00:00"},
timezone="UTC",
next_run_at=None,
)
task["status"] = "completed"
future_run_at = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
request = SimpleNamespace()
user = SimpleNamespace(id="user-1")
old_repo = scheduled_tasks.get_scheduled_task_repo
old_config = scheduled_tasks.get_config
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_config = lambda: _Config()
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
result = await scheduled_tasks.update_scheduled_task.__wrapped__(
task_id=task["id"],
request=request,
body=scheduled_tasks.ScheduledTaskUpdateRequest(schedule_spec={"run_at": future_run_at}),
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_config = old_config
scheduled_tasks.get_optional_user_from_request = old_user
assert result["status"] == "enabled"
assert result["next_run_at"] is not None