mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 23:06:40 +08:00
344 lines
15 KiB
Python
344 lines
15 KiB
Python
"""Exercise detached file restoration against real SQLAlchemy and HTTP transports."""
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, replace
|
|
from datetime import datetime
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from threading import Thread
|
|
from typing import override
|
|
from urllib.parse import parse_qs, urlsplit
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from pydantic import JsonValue
|
|
from sqlalchemy import Connection, event, select
|
|
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
|
|
|
|
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
|
from core.app.file_access import FileAccessScope, bind_file_access_scope
|
|
from core.helper import http_client_pooling
|
|
from core.tools.signature import verify_tool_file_signature
|
|
from core.workflow.file_reference import build_file_reference, resolve_file_record_id
|
|
from extensions.storage.storage_type import StorageType
|
|
from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType
|
|
from models import AppModelConfig, MessageFile, ToolFile, UploadFile
|
|
from models.enums import CreatorUserRole, MessageFileBelongsTo
|
|
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
|
|
from services.entities.message_entities import MessageFileReference, MessageInputValue
|
|
from services.message_query_adapters import ExecutionExtraContentReader, MessageFileResolver
|
|
from tests.e2e_tests.containers.helpers.execution_extra_content import create_human_input_message_fixture
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def file_configuration(config_overrides: Callable[..., None]) -> None:
|
|
config_overrides(
|
|
FILES_URL="https://files.example.test",
|
|
SECRET_KEY="message-file-test-key",
|
|
SSRF_PROXY_HTTP_URL="",
|
|
SSRF_PROXY_HTTPS_URL="",
|
|
SSRF_PROXY_ALL_URL="",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _StoredFiles:
|
|
tenant_id: str
|
|
user_id: str
|
|
upload_id: str
|
|
tool_id: str
|
|
legacy: MessageFileReference
|
|
|
|
|
|
@pytest.fixture
|
|
def stored_files(sqlite_session_factory: sessionmaker[Session]) -> _StoredFiles:
|
|
tenant_id, user_id = str(uuid4()), str(uuid4())
|
|
with sqlite_session_factory.begin() as session:
|
|
upload = UploadFile(
|
|
tenant_id=tenant_id,
|
|
storage_type=StorageType.LOCAL,
|
|
key="uploads/canonical.txt",
|
|
name="canonical.txt",
|
|
size=17,
|
|
extension="txt",
|
|
mime_type="text/plain",
|
|
created_by_role=CreatorUserRole.END_USER,
|
|
created_by=user_id,
|
|
created_at=datetime(2026, 10, 9),
|
|
used=True,
|
|
)
|
|
tool = ToolFile(
|
|
user_id=user_id,
|
|
tenant_id=tenant_id,
|
|
conversation_id=None,
|
|
file_key="tools/result.pdf",
|
|
mimetype="application/pdf",
|
|
name="result.pdf",
|
|
size=23,
|
|
)
|
|
session.add_all([upload, tool])
|
|
session.flush()
|
|
legacy = MessageFile(
|
|
message_id=str(uuid4()),
|
|
type=FileType.DOCUMENT,
|
|
transfer_method=FileTransferMethod.TOOL_FILE,
|
|
url=f"https://old.example/files/tools/{tool.id}.pdf",
|
|
upload_file_id=None,
|
|
belongs_to=MessageFileBelongsTo.ASSISTANT,
|
|
created_by_role=CreatorUserRole.END_USER,
|
|
created_by=user_id,
|
|
)
|
|
session.add(legacy)
|
|
session.flush()
|
|
return _StoredFiles(tenant_id, user_id, upload.id, tool.id, _reference(legacy))
|
|
|
|
|
|
def _reference(row: MessageFile) -> MessageFileReference:
|
|
return MessageFileReference(
|
|
id=row.id,
|
|
type=row.type.value,
|
|
transfer_method=row.transfer_method.value,
|
|
url=row.url,
|
|
upload_file_id=row.upload_file_id,
|
|
belongs_to=row.belongs_to.value if row.belongs_to else None,
|
|
)
|
|
|
|
|
|
def _upload_reference(upload_id: str) -> MessageFileReference:
|
|
return MessageFileReference(
|
|
id=str(uuid4()),
|
|
type="document",
|
|
transfer_method="local_file",
|
|
url=None,
|
|
upload_file_id=upload_id,
|
|
belongs_to="user",
|
|
)
|
|
|
|
|
|
def _input_mapping(record_id: str, transfer_method: str = "local_file") -> dict[str, JsonValue]:
|
|
return {
|
|
"dify_model_identity": FILE_MODEL_IDENTITY,
|
|
"transfer_method": transfer_method,
|
|
"reference": build_file_reference(record_id=record_id),
|
|
"type": "document",
|
|
"filename": "stale.json",
|
|
"mime_type": "application/json",
|
|
"size": 999,
|
|
}
|
|
|
|
|
|
def test_remote_head_runs_after_query_and_file_sessions_close(
|
|
sqlite_session_factory: sessionmaker[Session], stored_files: _StoredFiles, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
sessions: list[Session] = []
|
|
commits: list[Session] = []
|
|
requests: list[tuple[str, str, bool]] = []
|
|
|
|
def observe(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
|
|
sessions.append(session)
|
|
|
|
def committed(session: Session) -> None:
|
|
commits.append(session)
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
@override
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
pass
|
|
|
|
def do_HEAD(self) -> None:
|
|
requests.append((self.command, self.path, any(session.in_transaction() for session in sessions)))
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/octet-stream")
|
|
self.send_header("Content-Length", "29")
|
|
self.send_header("Content-Disposition", "attachment; filename*=UTF-8''server%20report.txt")
|
|
self.end_headers()
|
|
|
|
http_pool = http_client_pooling.HttpClientPoolFactory()
|
|
monkeypatch.setattr(http_client_pooling, "_factory", http_pool)
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
thread = Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
event.listen(sqlite_session_factory, "after_begin", observe)
|
|
event.listen(sqlite_session_factory, "after_commit", committed)
|
|
try:
|
|
with sqlite_session_factory() as session:
|
|
row = session.scalar(select(MessageFile).where(MessageFile.id == stored_files.legacy.id))
|
|
assert row is not None
|
|
source = _reference(row)
|
|
remote = replace(
|
|
source,
|
|
id=str(uuid4()),
|
|
transfer_method="remote_url",
|
|
url=f"http://127.0.0.1:{server.server_port}/download.bin",
|
|
belongs_to="user",
|
|
)
|
|
result = MessageFileResolver().resolve(
|
|
tenant_id=stored_files.tenant_id,
|
|
inputs={"upload": _input_mapping(stored_files.upload_id)},
|
|
answer="Answer",
|
|
files=[source, remote],
|
|
)
|
|
assert requests == [("HEAD", "/download.bin", False)]
|
|
assert not commits
|
|
assert sessions
|
|
assert all(not session.in_transaction() and not session.identity_map for session in sessions)
|
|
assert result.files[1].filename == "server report.txt"
|
|
assert result.files[1].mime_type == "text/plain"
|
|
assert result.files[1].size == 29
|
|
finally:
|
|
event.remove(sqlite_session_factory, "after_begin", observe)
|
|
event.remove(sqlite_session_factory, "after_commit", committed)
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join()
|
|
http_pool.close_all()
|
|
|
|
|
|
def test_canonical_files_and_legacy_tool_fallback_do_not_write(
|
|
sqlite_session_factory: sessionmaker[Session], stored_files: _StoredFiles
|
|
) -> None:
|
|
inputs: dict[str, MessageInputValue] = {
|
|
"upload": _input_mapping(stored_files.upload_id),
|
|
"tools": [_input_mapping(stored_files.tool_id, "tool_file")],
|
|
"empty": [],
|
|
"mixed": [_input_mapping(stored_files.upload_id), "leave unchanged"],
|
|
"zero": 0,
|
|
"false": False,
|
|
"none": None,
|
|
}
|
|
with sqlite_session_factory() as pending:
|
|
config = AppModelConfig(app_id=str(uuid4()), opening_statement="Unrelated pending write")
|
|
pending.add(config)
|
|
result = MessageFileResolver().resolve(
|
|
tenant_id=stored_files.tenant_id,
|
|
inputs=inputs,
|
|
answer="Answer",
|
|
files=[_upload_reference(stored_files.upload_id), stored_files.legacy],
|
|
)
|
|
assert config in pending.new
|
|
assert [file.belongs_to for file in result.files] == ["user", "assistant"]
|
|
assert [file.filename for file in result.files] == ["canonical.txt", "result.pdf"]
|
|
assert [file.size for file in result.files] == [17, 23]
|
|
assert result.files[1].upload_file_id == stored_files.tool_id
|
|
assert stored_files.legacy.upload_file_id is None
|
|
upload = result.inputs["upload"]
|
|
assert isinstance(upload, File)
|
|
assert (upload.filename, upload.mime_type, upload.size) == ("canonical.txt", "text/plain", 17)
|
|
assert resolve_file_record_id(upload.reference) == stored_files.upload_id
|
|
tools = result.inputs["tools"]
|
|
assert isinstance(tools, list)
|
|
assert isinstance(tools[0], File)
|
|
assert tools[0].filename == "result.pdf"
|
|
assert result.inputs["empty"] == []
|
|
assert result.inputs["mixed"] == inputs["mixed"]
|
|
assert result.inputs["zero"] == 0
|
|
assert result.inputs["false"] is False
|
|
assert result.inputs["none"] is None
|
|
assert isinstance(inputs["upload"], dict)
|
|
assert inputs["upload"]["filename"] == "stale.json"
|
|
with sqlite_session_factory() as session:
|
|
row = session.get(MessageFile, stored_files.legacy.id)
|
|
assert row is not None
|
|
assert row.upload_file_id is None
|
|
assert session.scalar(select(AppModelConfig.id).where(AppModelConfig.id == config.id)) is None
|
|
|
|
|
|
def test_input_external_urls_and_embedded_legacy_tenant_are_preserved(stored_files: _StoredFiles) -> None:
|
|
mapping = _input_mapping(stored_files.upload_id)
|
|
mapping["tenant_id"] = stored_files.tenant_id
|
|
remote: dict[str, JsonValue] = {
|
|
"dify_model_identity": FILE_MODEL_IDENTITY,
|
|
"type": "document",
|
|
"transfer_method": "remote_url",
|
|
"url": "http://127.0.0.1:1/never-probed.txt",
|
|
"filename": "stored.txt",
|
|
"extension": ".txt",
|
|
"mime_type": "text/plain",
|
|
"size": 31,
|
|
}
|
|
result = MessageFileResolver().resolve(
|
|
tenant_id=str(uuid4()), inputs={"local": mapping, "remote": remote}, answer="", files=[]
|
|
)
|
|
local = result.inputs["local"]
|
|
assert isinstance(local, File)
|
|
assert local.filename == "canonical.txt"
|
|
external = result.inputs["remote"]
|
|
assert isinstance(external, File)
|
|
assert (external.remote_url, external.filename, external.size) == (remote["url"], "stored.txt", 31)
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["upload", "tool"])
|
|
def test_file_restoration_keeps_tenant_and_end_user_filters(stored_files: _StoredFiles, kind: str) -> None:
|
|
source = _upload_reference(stored_files.upload_id) if kind == "upload" else stored_files.legacy
|
|
resolver = MessageFileResolver()
|
|
with pytest.raises(ValueError, match="Invalid upload file|ToolFile .* not found"):
|
|
resolver.resolve(tenant_id=str(uuid4()), inputs={}, answer="", files=[source])
|
|
denied_scope = FileAccessScope(
|
|
tenant_id=stored_files.tenant_id,
|
|
user_id=str(uuid4()),
|
|
user_from=UserFrom.END_USER,
|
|
invoke_from=InvokeFrom.WEB_APP,
|
|
)
|
|
with bind_file_access_scope(denied_scope):
|
|
with pytest.raises(ValueError, match="Invalid upload file|ToolFile .* not found"):
|
|
resolver.resolve(tenant_id=stored_files.tenant_id, inputs={}, answer="", files=[source])
|
|
with bind_file_access_scope(replace(denied_scope, user_id=stored_files.user_id)):
|
|
result = resolver.resolve(tenant_id=stored_files.tenant_id, inputs={}, answer="", files=[source])
|
|
assert len(result.files) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("transfer_method", "upload_file_id", "url", "error", "message"),
|
|
[
|
|
("local_file", None, None, ValueError, "is a local file but has no upload_file_id"),
|
|
("local_file", "malformed", None, ValueError, "Invalid upload file id format"),
|
|
("local_file", "10000000-0000-4000-8000-000000000001", None, ValueError, "Invalid upload file"),
|
|
("remote_url", None, None, ValueError, "is a remote url but has no url"),
|
|
("tool_file", None, None, AssertionError, "^$"),
|
|
("tool_file", "missing", None, ValueError, "ToolFile missing not found"),
|
|
("datasource_file", None, None, ValueError, "has an invalid transfer_method datasource_file"),
|
|
],
|
|
)
|
|
def test_missing_and_invalid_file_references_keep_existing_errors(
|
|
transfer_method: str, upload_file_id: str | None, url: str | None, error: type[Exception], message: str
|
|
) -> None:
|
|
source = MessageFileReference(str(uuid4()), "document", transfer_method, url, upload_file_id, None)
|
|
with pytest.raises(error, match=message):
|
|
MessageFileResolver().resolve(tenant_id=str(uuid4()), inputs={}, answer="", files=[source])
|
|
|
|
|
|
@pytest.mark.parametrize("shape", ["{}", "`{}`", "[download]({})"])
|
|
@pytest.mark.parametrize("kind", ["tool", "file-preview", "image-preview"])
|
|
def test_answer_signing_preserves_legacy_url_shapes(shape: str, kind: str) -> None:
|
|
file_id = str(uuid4())
|
|
path = f"/files/tools/{file_id}.pdf" if kind == "tool" else f"/files/{file_id}/{kind}"
|
|
original = f"http://internal.example{path}?timestamp=1&nonce=old&sign=old&as_attachment=true"
|
|
result = MessageFileResolver().resolve(tenant_id=str(uuid4()), inputs={}, answer=shape.format(original), files=[])
|
|
signed = result.answer.removeprefix("[download](").removesuffix(")").strip("`")
|
|
parsed = urlsplit(signed)
|
|
query = parse_qs(parsed.query)
|
|
assert parsed.netloc == "files.example.test"
|
|
assert query["as_attachment"] == ["true"]
|
|
assert query["nonce"] != ["old"]
|
|
assert query["timestamp"] != ["1"]
|
|
assert verify_tool_file_signature(file_id, query["timestamp"][0], query["nonce"][0], query["sign"][0])
|
|
assert parsed.path == (f"/files/tools/{file_id}.pdf" if kind == "tool" else f"/files/{file_id}/file-preview")
|
|
|
|
|
|
def test_extra_content_reader_serializes_real_database_content(sqlite_session_factory: sessionmaker[Session]) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
fixture = create_human_input_message_fixture(session)
|
|
reader = ExecutionExtraContentReader(
|
|
repository=SQLAlchemyExecutionExtraContentRepository(session_maker=sqlite_session_factory)
|
|
)
|
|
result = reader.get_by_message_ids(["missing", fixture.message.id])
|
|
assert result[0] == []
|
|
assert result[1][0]["type"] == "human_input"
|
|
assert result[1][0]["workflow_run_id"] == fixture.message.workflow_run_id
|
|
assert result[1][0]["submitted"] is True
|
|
definition = result[1][0]["form_definition"]
|
|
assert isinstance(definition, dict)
|
|
assert "form_token" not in definition
|
|
submission = result[1][0]["form_submission_data"]
|
|
assert isinstance(submission, dict)
|
|
assert submission["submitted_data"] == {"name": "Alice"}
|