deer-flow/backend/tests/blocking_io/test_artifacts_router.py
Willem Jiang 03afd05917
fix(artifacts): authorize shared image reads and offload MIME lookup (#7604)
* fix(artifacts): authorize shared image reads and offload MIME lookup

* fix(artifacts): skip disabled blob lookups and warn on failures
2026-10-27 16:59:19 +08:00

197 lines
8.7 KiB
Python

"""Regression anchor: serving artifacts must not block the event loop.
``get_artifact`` probes the artifact path (``exists`` / ``is_file``), sniffs
text-ness (``is_text_file_by_content``),
and extracts ``.skill`` archive members — all blocking filesystem IO. The
handler offloads each branch's IO via ``asyncio.to_thread``; if any regresses
back onto the event loop, the strict Blockbuster gate raises ``BlockingError``
and these tests fail.
Binary (non-text, non-active-content) artifacts still run the same
``exists`` / ``is_file`` / ``mimetypes.guess_type`` / ``is_text_file_by_content``
probes as the text branch, offloaded the same way via ``asyncio.to_thread``.
What differs is the payload: instead of a read, the handler returns a
``FileResponse`` that defers its own file IO to ASGI response time (streamed
via ``anyio.open_file`` when the response is actually sent, which this test
never triggers) — so awaiting ``get_artifact`` itself for a binary artifact
does no full-file read; ``FileResponse`` streams the bytes at ASGI send
time, off the loop, which is why the gate has nothing to catch there
either way.
The ``@require_permission`` decorator is bypassed via ``__wrapped__`` so the
anchor exercises the handler's own filesystem IO, not the authz layer. Imports
sit at module top so any import-time IO runs at collection, outside the gate.
"""
from __future__ import annotations
import asyncio
import hashlib
import mimetypes
import zipfile
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from starlette.requests import Request
from starlette.responses import FileResponse
import app.gateway.routers.artifacts as artifacts_router
from app.gateway import services
from app.gateway.authz import AuthContext
from app.gateway.path_utils import resolve_thread_virtual_path
from app.gateway.routers.artifacts import ArtifactUpdateRequest, get_artifact, update_artifact
from deerflow.storage import BlobRef
pytestmark = pytest.mark.asyncio
# The undecorated coroutine (``require_permission`` uses ``functools.wraps``).
_get_artifact = get_artifact.__wrapped__
_update_artifact = update_artifact.__wrapped__
async def _seed(tmp_path: Path, monkeypatch, thread_id: str, virtual_path: str) -> Path:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
# Rebuild cached Paths against the tmp home so the artifact resolves under it.
import deerflow.config.paths as paths_mod
monkeypatch.setattr(paths_mod, "_paths", None)
# Test-side path resolution also touches the filesystem (`.resolve()`); offload
# it so this seeding helper doesn't itself trip the gate.
target = await asyncio.to_thread(resolve_thread_virtual_path, thread_id, virtual_path)
await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True)
return target
async def test_get_artifact_text_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
vpath = "mnt/user-data/outputs/notes.txt"
target = await _seed(tmp_path, monkeypatch, "t1", vpath)
await asyncio.to_thread(target.write_text, "hello world", encoding="utf-8")
resp = await _get_artifact("t1", vpath, request=None, download=False)
assert isinstance(resp, FileResponse)
assert resp.status_code == 200
assert Path(resp.path) == target
assert resp.headers.get("content-disposition", "").startswith("inline;")
async def test_get_artifact_binary_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
vpath = "mnt/user-data/outputs/blob.bin"
target = await _seed(tmp_path, monkeypatch, "t1", vpath)
payload = b"\x00\x01\x02PNGDATA" # null byte -> binary branch (inline FileResponse)
await asyncio.to_thread(target.write_bytes, payload)
resp = await _get_artifact("t1", vpath, request=None, download=False)
# Binary artifacts are streamed via FileResponse (so browsers can issue
# byte-Range requests) instead of being read into memory up front, so the
# file bytes are only touched later during ASGI send, never here.
assert isinstance(resp, FileResponse)
assert resp.status_code == 200
assert Path(resp.path) == target
assert resp.headers.get("content-disposition", "").startswith("inline;")
async def test_get_artifact_skill_archive_member_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
skill_vpath = "mnt/user-data/outputs/demo.skill"
target = await _seed(tmp_path, monkeypatch, "t1", skill_vpath)
def _build_skill_zip() -> None:
with zipfile.ZipFile(target, "w") as zf:
zf.writestr("SKILL.md", "# demo skill\n")
await asyncio.to_thread(_build_skill_zip)
resp = await _get_artifact("t1", f"{skill_vpath}/SKILL.md", request=None, download=False)
assert resp.status_code == 200
assert b"# demo skill" in resp.body
async def test_update_artifact_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
vpath = "/mnt/user-data/outputs/notes.txt"
target = await _seed(tmp_path, monkeypatch, "t1", vpath)
original = b"hello world"
await asyncio.to_thread(target.write_bytes, original)
@asynccontextmanager
async def allow_write(*_args, **_kwargs):
yield
class MountedProvider:
uses_thread_data_mounts = True
monkeypatch.setattr(artifacts_router, "reserve_artifact_write", allow_write)
monkeypatch.setattr(artifacts_router, "get_sandbox_provider", lambda: MountedProvider())
result = await _update_artifact(
"t1",
vpath,
ArtifactUpdateRequest(
content="updated",
expected_sha256=hashlib.sha256(original).hexdigest(),
),
request=None,
)
assert result.sha256 == hashlib.sha256(b"updated").hexdigest()
assert await asyncio.to_thread(target.read_bytes) == b"updated"
@pytest.mark.parametrize("filename", ["large.txt", "large.html", "large.bin"])
@pytest.mark.parametrize("download", [False, True])
async def test_large_artifact_validator_does_not_block_event_loop(tmp_path: Path, monkeypatch, filename: str, download: bool) -> None:
vpath = f"mnt/user-data/outputs/{filename}"
target = await _seed(tmp_path, monkeypatch, "t1", vpath)
await asyncio.to_thread(target.write_bytes, b"a" * (artifacts_router.MAX_EDITABLE_ARTIFACT_BYTES + 1))
def unexpected_hash(*_args, **_kwargs):
pytest.fail("Large artifact response planning must not hash the file")
monkeypatch.setattr(artifacts_router, "_sha256_of_file", unexpected_hash)
response = await _get_artifact("t1", vpath, request=None, download=download)
assert isinstance(response, FileResponse)
assert response.headers["etag"].startswith('"stat-')
async def test_shared_image_blob_cold_mime_lookup_does_not_block(tmp_path, monkeypatch):
payload = b"\x89PNG\r\n\x1a\nimage"
virtual_path = "/mnt/user-data/outputs/chart.png"
ref = BlobRef(sha256=hashlib.sha256(payload).hexdigest(), size=len(payload), kind="viewed-image", content_type="image/png")
mime_file = tmp_path / "mime.types"
await asyncio.to_thread(mime_file.write_text, "image/png png\n", encoding="utf-8")
# Restore all globals replaced by init(), not just its lazy-init flag.
for name in ("suffix_map", "encodings_map", "types_map", "common_types"):
monkeypatch.setattr(mimetypes, name, getattr(mimetypes, name))
monkeypatch.setattr(mimetypes, "inited", False)
monkeypatch.setattr(mimetypes, "_db", None)
monkeypatch.setattr(mimetypes, "knownfiles", [str(mime_file)])
class Accessor:
async def aget(self, _config):
return SimpleNamespace(values={"viewed_images": {virtual_path: {"mime_type": "image/png", "size": len(payload), "sha256": ref.sha256, "blob_ref": ref.model_dump(mode="json")}}})
async def build_accessor(_request, *, thread_id):
return Accessor(), {"configurable": {"thread_id": thread_id}}
class Store:
def get_bytes(self, requested_ref):
assert requested_ref == ref
return payload
monkeypatch.setattr(services, "build_thread_checkpoint_state_accessor", build_accessor)
monkeypatch.setattr("deerflow.storage.get_blob_store_if_enabled", lambda: Store())
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda *_args, **_kwargs: tmp_path / "missing.png")
request = Request({"type": "http", "method": "GET", "path": "/", "headers": [], "query_string": b"", "app": SimpleNamespace(state=SimpleNamespace(thread_store=SimpleNamespace(check_access=AsyncMock(return_value=True))))})
user = SimpleNamespace(id="owner", system_role="user")
request.state.user = user
request.state.auth = AuthContext(user=user, permissions=["threads:read"])
response = await _get_artifact("thread-1", virtual_path.lstrip("/"), request=request)
assert response.status_code == 200
assert response.body == payload