mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
test(api): add tests about file input file uploading api
This commit is contained in:
parent
37681bce8c
commit
a0f8db5516
@ -0,0 +1,186 @@
|
||||
"""Unit tests for HITL human input file upload endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
import controllers.web.human_input_file_upload as upload_module
|
||||
from controllers.common.errors import NoFileUploadedError
|
||||
from controllers.web.human_input_file_upload import (
|
||||
HumanInputFileUploadApi,
|
||||
HumanInputRemoteFileUploadApi,
|
||||
InvalidUploadTokenForbiddenError,
|
||||
InvalidUploadTokenUnauthorizedError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
|
||||
def _upload_context() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
form_id="form-1",
|
||||
upload_token_id="token-row-1",
|
||||
end_user=SimpleNamespace(id="end-user-1", tenant_id="tenant-1"),
|
||||
)
|
||||
|
||||
|
||||
def _upload_file() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id="file-1",
|
||||
name="sample.txt",
|
||||
size=7,
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
created_by="end-user-1",
|
||||
created_at=datetime(2024, 1, 1),
|
||||
tenant_id="tenant-1",
|
||||
source_url="signed-source-url",
|
||||
)
|
||||
|
||||
|
||||
def test_local_upload_requires_authorization_before_reading_files(app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b"content"), "sample.txt")}
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/form/human_input/files/upload",
|
||||
method="POST",
|
||||
data=data,
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
with pytest.raises(InvalidUploadTokenUnauthorizedError):
|
||||
HumanInputFileUploadApi().post()
|
||||
|
||||
|
||||
def test_local_upload_ignores_source_and_records_form_file_link(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
|
||||
service = MagicMock()
|
||||
service.validate_upload_token.return_value = _upload_context()
|
||||
monkeypatch.setattr(upload_module, "HumanInputFileUploadService", lambda engine: service)
|
||||
|
||||
file_service = MagicMock()
|
||||
file_service.upload_file.return_value = _upload_file()
|
||||
file_service_cls = MagicMock(return_value=file_service)
|
||||
monkeypatch.setattr(upload_module, "FileService", file_service_cls)
|
||||
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
data = {
|
||||
"file": (BytesIO(b"content"), "sample.txt"),
|
||||
"source": "datasets",
|
||||
}
|
||||
with app.test_request_context(
|
||||
"/api/form/human_input/files/upload",
|
||||
method="POST",
|
||||
headers={"Authorization": "bearer hitl_upload_token-1"},
|
||||
data=data,
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
result, status = HumanInputFileUploadApi().post()
|
||||
|
||||
assert status == 201
|
||||
assert result["id"] == "file-1"
|
||||
file_service.upload_file.assert_called_once()
|
||||
assert file_service.upload_file.call_args.kwargs["source"] is None
|
||||
assert file_service.upload_file.call_args.kwargs["user"].id == "end-user-1"
|
||||
service.record_upload_file.assert_called_once_with(
|
||||
context=service.validate_upload_token.return_value,
|
||||
file_id="file-1",
|
||||
)
|
||||
|
||||
|
||||
def test_local_upload_missing_file_raises_after_valid_token(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
|
||||
service = MagicMock()
|
||||
service.validate_upload_token.return_value = _upload_context()
|
||||
monkeypatch.setattr(upload_module, "HumanInputFileUploadService", lambda engine: service)
|
||||
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/form/human_input/files/upload",
|
||||
method="POST",
|
||||
headers={"Authorization": "bearer hitl_upload_token-1"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
with pytest.raises(NoFileUploadedError):
|
||||
HumanInputFileUploadApi().post()
|
||||
|
||||
service.validate_upload_token.assert_called_once_with("hitl_upload_token-1")
|
||||
|
||||
|
||||
def test_remote_upload_validates_token_before_fetching_remote_url(
|
||||
monkeypatch: pytest.MonkeyPatch, app: Flask
|
||||
) -> None:
|
||||
service = MagicMock()
|
||||
service.validate_upload_token.side_effect = InvalidUploadTokenForbiddenError()
|
||||
monkeypatch.setattr(upload_module, "HumanInputFileUploadService", lambda engine: service)
|
||||
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
|
||||
ssrf_proxy = MagicMock()
|
||||
monkeypatch.setattr(upload_module, "ssrf_proxy", ssrf_proxy)
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/form/human_input/files/remote-upload",
|
||||
method="POST",
|
||||
headers={"Authorization": "Bearer hitl_upload_token-1"},
|
||||
json={"url": "https://example.com/file.txt"},
|
||||
):
|
||||
with pytest.raises(InvalidUploadTokenForbiddenError):
|
||||
HumanInputRemoteFileUploadApi().post()
|
||||
|
||||
ssrf_proxy.head.assert_not_called()
|
||||
ssrf_proxy.get.assert_not_called()
|
||||
|
||||
|
||||
def test_remote_upload_records_form_file_link(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
|
||||
service = MagicMock()
|
||||
service.validate_upload_token.return_value = _upload_context()
|
||||
monkeypatch.setattr(upload_module, "HumanInputFileUploadService", lambda engine: service)
|
||||
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.content = b"remote"
|
||||
response.request.method = "GET"
|
||||
ssrf_proxy = MagicMock()
|
||||
ssrf_proxy.head.return_value = response
|
||||
monkeypatch.setattr(upload_module, "ssrf_proxy", ssrf_proxy)
|
||||
monkeypatch.setattr(
|
||||
upload_module.helpers,
|
||||
"guess_file_info_from_response",
|
||||
lambda _response: SimpleNamespace(filename="sample.txt", extension="txt", mimetype="text/plain", size=6),
|
||||
)
|
||||
|
||||
file_service = MagicMock()
|
||||
file_service.upload_file.return_value = _upload_file()
|
||||
file_service_cls = MagicMock(return_value=file_service)
|
||||
file_service_cls.is_file_size_within_limit.return_value = True
|
||||
monkeypatch.setattr(upload_module, "FileService", file_service_cls)
|
||||
monkeypatch.setattr(
|
||||
upload_module.file_helpers,
|
||||
"get_signed_file_url",
|
||||
lambda upload_file_id: f"signed:{upload_file_id}",
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/form/human_input/files/remote-upload",
|
||||
method="POST",
|
||||
headers={"Authorization": "Bearer hitl_upload_token-1"},
|
||||
json={"url": "https://example.com/file.txt"},
|
||||
):
|
||||
result, status = HumanInputRemoteFileUploadApi().post()
|
||||
|
||||
assert status == 201
|
||||
assert result["url"] == "signed:file-1"
|
||||
file_service.upload_file.assert_called_once()
|
||||
assert file_service.upload_file.call_args.kwargs["source_url"] == "https://example.com/file.txt"
|
||||
service.record_upload_file.assert_called_once_with(
|
||||
context=service.validate_upload_token.return_value,
|
||||
file_id="file-1",
|
||||
)
|
||||
@ -19,6 +19,7 @@ from models.human_input import RecipientType
|
||||
from services.human_input_service import FormExpiredError
|
||||
|
||||
HumanInputFormApi = human_input_module.HumanInputFormApi
|
||||
HumanInputFormUploadTokenApi = human_input_module.HumanInputFormUploadTokenApi
|
||||
TenantStatus = human_input_module.TenantStatus
|
||||
|
||||
|
||||
@ -192,6 +193,35 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
|
||||
|
||||
|
||||
def test_create_upload_token_returns_token_and_form_expiration(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""POST returns a HITL upload token for an active form token."""
|
||||
|
||||
expiration_time = datetime(2099, 1, 1, tzinfo=UTC)
|
||||
service_mock = MagicMock()
|
||||
service_mock.issue_upload_token.return_value = SimpleNamespace(
|
||||
upload_token="hitl_upload_token-1",
|
||||
expires_at=expiration_time,
|
||||
)
|
||||
monkeypatch.setattr(human_input_module, "HumanInputFileUploadService", lambda engine: service_mock)
|
||||
monkeypatch.setattr(human_input_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
limiter_mock = MagicMock()
|
||||
limiter_mock.is_rate_limited.return_value = False
|
||||
monkeypatch.setattr(human_input_module, "_FORM_UPLOAD_TOKEN_RATE_LIMITER", limiter_mock)
|
||||
monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
|
||||
|
||||
with app.test_request_context("/api/form/human_input/token-1/upload-token", method="POST"):
|
||||
result, status = HumanInputFormUploadTokenApi().post("token-1")
|
||||
|
||||
assert status == 200
|
||||
assert result == {
|
||||
"upload_token": "hitl_upload_token-1",
|
||||
"expires_at": int(expiration_time.timestamp()),
|
||||
}
|
||||
service_mock.issue_upload_token.assert_called_once_with("token-1")
|
||||
limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
|
||||
|
||||
|
||||
def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""GET returns form payload for backstage token."""
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ from graphon.nodes.human_input.enums import (
|
||||
)
|
||||
from graphon.nodes.human_input.human_input_node import HumanInputNode
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
|
||||
|
||||
@ -809,4 +809,115 @@ class TestHumanInputNodeRenderedContent:
|
||||
last_event = events[-1]
|
||||
assert isinstance(last_event, StreamCompletedEvent)
|
||||
node_run_result = last_event.node_run_result
|
||||
assert node_run_result.outputs["__rendered_content"] == "Name: Alice"
|
||||
assert node_run_result.outputs["name"] == StringSegment(value="Alice")
|
||||
assert node_run_result.outputs["__action_id"] == StringSegment(value="approve")
|
||||
assert node_run_result.outputs["__rendered_content"] == StringSegment(value="Name: Alice")
|
||||
|
||||
def test_resume_restores_file_outputs_as_runtime_segments(self):
|
||||
variable_pool = VariablePool(
|
||||
system_variables=build_system_variables(
|
||||
user_id="user",
|
||||
app_id="app",
|
||||
workflow_id="workflow",
|
||||
workflow_execution_id="run",
|
||||
),
|
||||
user_inputs={},
|
||||
conversation_variables=[],
|
||||
)
|
||||
runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
|
||||
graph_init_params = GraphInitParams(
|
||||
workflow_id="workflow",
|
||||
graph_config={"nodes": [], "edges": []},
|
||||
run_context={
|
||||
DIFY_RUN_CONTEXT_KEY: {
|
||||
"tenant_id": "tenant",
|
||||
"app_id": "app",
|
||||
"user_id": "user",
|
||||
"user_from": "account",
|
||||
"invoke_from": "debugger",
|
||||
}
|
||||
},
|
||||
call_depth=0,
|
||||
)
|
||||
|
||||
node_data = HumanInputNodeData(
|
||||
title="Human Input",
|
||||
form_content=(
|
||||
"Decision: {{#$output.decision#}}\n"
|
||||
"Attachment: {{#$output.attachment#}}\n"
|
||||
"Attachments: {{#$output.attachments#}}"
|
||||
),
|
||||
inputs=[
|
||||
SelectInputConfig(
|
||||
output_variable_name="decision",
|
||||
option_source=StringListSource(type="constant", value=["approve", "reject"]),
|
||||
),
|
||||
FileInputConfig(output_variable_name="attachment"),
|
||||
FileListInputConfig(output_variable_name="attachments", number_limits=2),
|
||||
],
|
||||
user_actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
)
|
||||
config = {"id": "human", "data": node_data.model_dump()}
|
||||
|
||||
form_repository = InMemoryHumanInputFormRepository()
|
||||
runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
|
||||
runtime._build_form_repository = MagicMock(return_value=form_repository) # type: ignore[attr-defined]
|
||||
node = _build_human_input_node(
|
||||
node_id=config["id"],
|
||||
node_data=config["data"],
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=runtime_state,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
pause_gen = node._run()
|
||||
pause_event = next(pause_gen)
|
||||
assert isinstance(pause_event, PauseRequestedEvent)
|
||||
with pytest.raises(StopIteration):
|
||||
next(pause_gen)
|
||||
|
||||
form_repository.set_submission(
|
||||
action_id="approve",
|
||||
form_data={
|
||||
"decision": "approve",
|
||||
"attachment": {
|
||||
"type": "document",
|
||||
"transfer_method": "remote_url",
|
||||
"remote_url": "https://example.com/resume.pdf",
|
||||
"filename": "resume.pdf",
|
||||
"extension": ".pdf",
|
||||
"mime_type": "application/pdf",
|
||||
},
|
||||
"attachments": [
|
||||
{
|
||||
"type": "image",
|
||||
"transfer_method": "remote_url",
|
||||
"remote_url": "https://example.com/a.png",
|
||||
"filename": "a.png",
|
||||
"extension": ".png",
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"transfer_method": "remote_url",
|
||||
"remote_url": "https://example.com/b.png",
|
||||
"filename": "b.png",
|
||||
"extension": ".png",
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
events = list(node._run())
|
||||
last_event = events[-1]
|
||||
assert isinstance(last_event, StreamCompletedEvent)
|
||||
node_run_result = last_event.node_run_result
|
||||
assert node_run_result.outputs["decision"] == StringSegment(value="approve")
|
||||
assert node_run_result.outputs["__rendered_content"] == StringSegment(
|
||||
value="Decision: approve\nAttachment: [file]\nAttachments: [2 files]"
|
||||
)
|
||||
assert isinstance(node_run_result.outputs["attachment"], FileSegment)
|
||||
assert node_run_result.outputs["attachment"].value.filename == "resume.pdf"
|
||||
assert isinstance(node_run_result.outputs["attachments"], ArrayFileSegment)
|
||||
assert [file.filename for file in node_run_result.outputs["attachments"].value] == ["a.png", "b.png"]
|
||||
|
||||
@ -59,7 +59,14 @@ def _create_human_input_node(
|
||||
)
|
||||
|
||||
|
||||
def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name#}}") -> HumanInputNode:
|
||||
def _build_node(
|
||||
form_content: str = (
|
||||
"Please enter your name:\n\n{{#$output.name#}}\n"
|
||||
"Decision: {{#$output.decision#}}\n"
|
||||
"Attachment: {{#$output.attachment#}}\n"
|
||||
"Attachments: {{#$output.attachments#}}"
|
||||
),
|
||||
) -> HumanInputNode:
|
||||
system_variables = default_system_variables()
|
||||
graph_runtime_state = GraphRuntimeState(
|
||||
variable_pool=VariablePool(system_variables=system_variables, user_inputs={}, environment_variables=[]),
|
||||
@ -200,9 +207,25 @@ def test_human_input_node_emits_form_filled_event_before_succeeded():
|
||||
|
||||
filled_event = events[1]
|
||||
assert filled_event.node_title == "Human Input"
|
||||
assert filled_event.rendered_content.endswith("Alice")
|
||||
assert filled_event.rendered_content == (
|
||||
"Please enter your name:\n\nAlice\n"
|
||||
"Decision: approve\n"
|
||||
"Attachment: [file]\n"
|
||||
"Attachments: [1 files]"
|
||||
)
|
||||
assert filled_event.action_id == "Accept"
|
||||
assert filled_event.action_text == "Approve"
|
||||
assert filled_event.submitted_data["name"] == StringSegment(value="Alice")
|
||||
assert filled_event.submitted_data["decision"] == StringSegment(value="approve")
|
||||
assert isinstance(filled_event.submitted_data["attachment"], FileSegment)
|
||||
assert filled_event.submitted_data["attachment"].value_type == SegmentType.FILE
|
||||
assert filled_event.submitted_data["attachment"].value.filename == "resume.pdf"
|
||||
assert filled_event.submitted_data["attachment"].value.type == FileType.DOCUMENT
|
||||
assert filled_event.submitted_data["attachment"].value.transfer_method == FileTransferMethod.REMOTE_URL
|
||||
assert isinstance(filled_event.submitted_data["attachments"], ArrayFileSegment)
|
||||
assert filled_event.submitted_data["attachments"].value_type == SegmentType.ARRAY_FILE
|
||||
assert filled_event.submitted_data["attachments"].value[0].filename == "a.png"
|
||||
assert filled_event.submitted_data["attachments"].value[0].type == FileType.IMAGE
|
||||
|
||||
|
||||
def test_human_input_node_emits_timeout_event_before_succeeded():
|
||||
|
||||
@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import services.human_input_file_upload_service as service_module
|
||||
from graphon.nodes.human_input.enums import HumanInputFormStatus
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.base import Base
|
||||
from models.human_input import (
|
||||
HumanInputForm,
|
||||
HumanInputFormRecipient,
|
||||
HumanInputFormUploadFile,
|
||||
HumanInputFormUploadToken,
|
||||
)
|
||||
from models.model import EndUser
|
||||
from services.human_input_file_upload_service import (
|
||||
HITL_UPLOAD_TOKEN_PREFIX,
|
||||
HUMAN_INPUT_END_USER_SESSION_PREFIX,
|
||||
HUMAN_INPUT_END_USER_TYPE,
|
||||
HumanInputFileUploadService,
|
||||
)
|
||||
from services.human_input_service import FormSubmittedError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_maker():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
EndUser.__table__,
|
||||
HumanInputForm.__table__,
|
||||
HumanInputFormRecipient.__table__,
|
||||
HumanInputFormUploadToken.__table__,
|
||||
HumanInputFormUploadFile.__table__,
|
||||
],
|
||||
)
|
||||
try:
|
||||
yield sessionmaker(bind=engine, expire_on_commit=False)
|
||||
finally:
|
||||
Base.metadata.drop_all(
|
||||
engine,
|
||||
tables=[
|
||||
HumanInputFormUploadFile.__table__,
|
||||
HumanInputFormUploadToken.__table__,
|
||||
HumanInputFormRecipient.__table__,
|
||||
HumanInputForm.__table__,
|
||||
EndUser.__table__,
|
||||
],
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _create_waiting_form(session_maker) -> tuple[str, str]:
|
||||
form_id = "00000000-0000-0000-0000-000000000001"
|
||||
recipient_id = "00000000-0000-0000-0000-000000000002"
|
||||
now = naive_utc_now()
|
||||
with session_maker.begin() as session:
|
||||
session.add(
|
||||
HumanInputForm(
|
||||
id=form_id,
|
||||
tenant_id="00000000-0000-0000-0000-000000000010",
|
||||
app_id="00000000-0000-0000-0000-000000000011",
|
||||
workflow_run_id="00000000-0000-0000-0000-000000000012",
|
||||
node_id="node-1",
|
||||
form_definition="{}",
|
||||
rendered_content="content",
|
||||
expiration_time=now + timedelta(hours=1),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
HumanInputFormRecipient(
|
||||
id=recipient_id,
|
||||
form_id=form_id,
|
||||
delivery_id="00000000-0000-0000-0000-000000000003",
|
||||
recipient_type="standalone_web_app",
|
||||
recipient_payload='{"TYPE": "standalone_web_app"}',
|
||||
access_token="form-token-1",
|
||||
)
|
||||
)
|
||||
return form_id, recipient_id
|
||||
|
||||
|
||||
def test_issue_upload_token_creates_technical_end_user_and_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
session_maker,
|
||||
) -> None:
|
||||
form_id, recipient_id = _create_waiting_form(session_maker)
|
||||
monkeypatch.setattr(service_module.secrets, "token_urlsafe", lambda _bytes: "random-value")
|
||||
|
||||
token = HumanInputFileUploadService(session_maker).issue_upload_token("form-token-1")
|
||||
|
||||
assert token.upload_token == f"{HITL_UPLOAD_TOKEN_PREFIX}random-value"
|
||||
with session_maker() as session:
|
||||
end_user = session.scalar(select(EndUser).where(EndUser.type == HUMAN_INPUT_END_USER_TYPE))
|
||||
assert end_user is not None
|
||||
assert end_user.session_id == f"{HUMAN_INPUT_END_USER_SESSION_PREFIX}{recipient_id}"
|
||||
|
||||
token_model = session.scalar(select(HumanInputFormUploadToken))
|
||||
assert token_model is not None
|
||||
assert token_model.form_id == form_id
|
||||
assert token_model.recipient_id == recipient_id
|
||||
assert token_model.end_user_id == end_user.id
|
||||
assert token_model.token == token.upload_token
|
||||
|
||||
|
||||
def test_validate_upload_token_and_record_file(session_maker) -> None:
|
||||
form_id, recipient_id = _create_waiting_form(session_maker)
|
||||
token = HumanInputFileUploadService(session_maker).issue_upload_token("form-token-1")
|
||||
|
||||
context = HumanInputFileUploadService(session_maker).validate_upload_token(token.upload_token)
|
||||
assert context.form_id == form_id
|
||||
assert context.recipient_id == recipient_id
|
||||
assert context.end_user.type == HUMAN_INPUT_END_USER_TYPE
|
||||
|
||||
HumanInputFileUploadService(session_maker).record_upload_file(
|
||||
context=context,
|
||||
file_id="00000000-0000-0000-0000-000000000099",
|
||||
)
|
||||
|
||||
with session_maker() as session:
|
||||
link = session.scalar(select(HumanInputFormUploadFile))
|
||||
assert link is not None
|
||||
assert link.form_id == form_id
|
||||
assert link.upload_token_id == context.upload_token_id
|
||||
assert link.end_user_id == context.end_user.id
|
||||
|
||||
|
||||
def test_validate_upload_token_rejects_submitted_form(session_maker) -> None:
|
||||
form_id, _recipient_id = _create_waiting_form(session_maker)
|
||||
token = HumanInputFileUploadService(session_maker).issue_upload_token("form-token-1")
|
||||
with session_maker.begin() as session:
|
||||
form = session.get(HumanInputForm, form_id)
|
||||
assert form is not None
|
||||
form.status = HumanInputFormStatus.SUBMITTED
|
||||
form.submitted_at = naive_utc_now()
|
||||
|
||||
with pytest.raises(FormSubmittedError):
|
||||
HumanInputFileUploadService(session_maker).validate_upload_token(token.upload_token)
|
||||
@ -299,6 +299,157 @@ def test_submit_form_by_token_missing_inputs(sample_form_record, mock_session_fa
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_human_input_submission_accepts_select_file_and_file_list(mock_session_factory):
|
||||
session_factory, _ = mock_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
definition = FormDefinition.model_validate(
|
||||
{
|
||||
"form_content": "Pick one and upload files",
|
||||
"inputs": [
|
||||
{
|
||||
"type": "select",
|
||||
"output_variable_name": "decision",
|
||||
"option_source": {
|
||||
"type": "constant",
|
||||
"value": ["approve", "reject"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"output_variable_name": "attachment",
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
},
|
||||
{
|
||||
"type": "file-list",
|
||||
"output_variable_name": "attachments",
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
"number_limits": 3,
|
||||
},
|
||||
],
|
||||
"user_actions": [{"id": "submit", "title": "Submit"}],
|
||||
"rendered_content": "<p>Pick one and upload files</p>",
|
||||
"expiration_time": naive_utc_now() + timedelta(hours=1),
|
||||
}
|
||||
)
|
||||
|
||||
service.validate_human_input_submission(
|
||||
form_definition=definition,
|
||||
selected_action_id="submit",
|
||||
form_data={
|
||||
"decision": "approve",
|
||||
"attachment": {
|
||||
"type": "document",
|
||||
"transfer_method": "remote_url",
|
||||
"remote_url": "https://example.com/file.txt",
|
||||
"filename": "file.txt",
|
||||
"extension": ".txt",
|
||||
"mime_type": "text/plain",
|
||||
},
|
||||
"attachments": [
|
||||
{
|
||||
"type": "document",
|
||||
"transfer_method": "remote_url",
|
||||
"remote_url": "https://example.com/first.txt",
|
||||
"filename": "first.txt",
|
||||
"extension": ".txt",
|
||||
"mime_type": "text/plain",
|
||||
},
|
||||
{
|
||||
"type": "document",
|
||||
"transfer_method": "remote_url",
|
||||
"remote_url": "https://example.com/second.txt",
|
||||
"filename": "second.txt",
|
||||
"extension": ".txt",
|
||||
"mime_type": "text/plain",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_definition", "submitted_value", "expected_message"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"type": "select",
|
||||
"output_variable_name": "decision",
|
||||
"option_source": {
|
||||
"type": "constant",
|
||||
"value": ["approve", "reject"],
|
||||
},
|
||||
},
|
||||
"unknown",
|
||||
"decision",
|
||||
),
|
||||
(
|
||||
{
|
||||
"type": "file",
|
||||
"output_variable_name": "attachment",
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
},
|
||||
"not-a-file",
|
||||
"attachment",
|
||||
),
|
||||
(
|
||||
{
|
||||
"type": "file-list",
|
||||
"output_variable_name": "attachments",
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
"number_limits": 2,
|
||||
},
|
||||
[
|
||||
{
|
||||
"type": "document",
|
||||
"transfer_method": "remote_url",
|
||||
"remote_url": "https://example.com/ok.txt",
|
||||
"filename": "ok.txt",
|
||||
"extension": ".txt",
|
||||
"mime_type": "text/plain",
|
||||
},
|
||||
"not-a-file",
|
||||
],
|
||||
"attachments",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads(
|
||||
sample_form_record,
|
||||
mock_session_factory,
|
||||
input_definition,
|
||||
submitted_value,
|
||||
expected_message,
|
||||
):
|
||||
session_factory, _ = mock_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition.model_validate(
|
||||
{
|
||||
"form_content": "Validate form data",
|
||||
"inputs": [input_definition],
|
||||
"user_actions": [{"id": "submit", "title": "Submit"}],
|
||||
"rendered_content": "<p>Validate form data</p>",
|
||||
"expiration_time": naive_utc_now() + timedelta(hours=1),
|
||||
}
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError) as exc_info:
|
||||
service.submit_form_by_token(
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
form_token="token",
|
||||
selected_action_id="submit",
|
||||
form_data={input_definition["output_variable_name"]: submitted_value},
|
||||
)
|
||||
|
||||
assert expected_message in str(exc_info.value)
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_form_properties(sample_form_record):
|
||||
form = Form(sample_form_record)
|
||||
assert form.id == "form-id"
|
||||
@ -466,3 +617,203 @@ def test_is_globally_expired_zero_timeout(monkeypatch, sample_form_record, mock_
|
||||
|
||||
monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
|
||||
assert service._is_globally_expired(Form(sample_form_record)) is False
|
||||
|
||||
|
||||
def test_submit_form_by_token_normalizes_select_and_files(sample_form_record, mock_session_factory, mocker) -> None:
|
||||
session_factory, _ = mock_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
inputs=[
|
||||
SelectInputConfig(
|
||||
output_variable_name="decision",
|
||||
option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]),
|
||||
),
|
||||
FileInputConfig(output_variable_name="attachment"),
|
||||
FileListInputConfig(output_variable_name="attachments", number_limits=3),
|
||||
],
|
||||
user_actions=[UserActionConfig(id="submit", title="Submit")],
|
||||
rendered_content="<p>hello</p>",
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
form_with_inputs = dataclasses.replace(sample_form_record, definition=definition)
|
||||
repo.get_by_token.return_value = form_with_inputs
|
||||
repo.mark_submitted.return_value = form_with_inputs
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
single_file = File(
|
||||
file_id="file-1",
|
||||
file_type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
related_id="upload-1",
|
||||
filename="resume.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=128,
|
||||
)
|
||||
list_files = [
|
||||
File(
|
||||
file_id="file-2",
|
||||
file_type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
related_id="upload-2",
|
||||
filename="a.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=64,
|
||||
),
|
||||
File(
|
||||
file_id="file-3",
|
||||
file_type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.REMOTE_URL,
|
||||
remote_url="https://example.com/b.pdf",
|
||||
filename="b.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=96,
|
||||
),
|
||||
]
|
||||
mocker.patch("services.human_input_service.build_from_mapping", return_value=single_file)
|
||||
mocker.patch("services.human_input_service.build_from_mappings", return_value=list_files)
|
||||
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
|
||||
|
||||
service.submit_form_by_token(
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
form_token="token",
|
||||
selected_action_id="submit",
|
||||
form_data={
|
||||
"decision": "approve",
|
||||
"attachment": {"transfer_method": "local_file", "upload_file_id": "upload-1", "type": "document"},
|
||||
"attachments": [
|
||||
{"transfer_method": "local_file", "upload_file_id": "upload-2", "type": "document"},
|
||||
{"transfer_method": "remote_url", "url": "https://example.com/b.pdf", "type": "document"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
submitted_data = repo.mark_submitted.call_args.kwargs["form_data"]
|
||||
assert submitted_data["decision"] == "approve"
|
||||
assert submitted_data["attachment"]["filename"] == "resume.pdf"
|
||||
assert submitted_data["attachment"]["transfer_method"] == "local_file"
|
||||
assert submitted_data["attachments"][0]["filename"] == "a.pdf"
|
||||
assert submitted_data["attachments"][1]["filename"] == "b.pdf"
|
||||
enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id)
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_select_value(sample_form_record, mock_session_factory) -> None:
|
||||
session_factory, _ = mock_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
inputs=[
|
||||
SelectInputConfig(
|
||||
output_variable_name="decision",
|
||||
option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]),
|
||||
)
|
||||
],
|
||||
user_actions=[UserActionConfig(id="submit", title="Submit")],
|
||||
rendered_content="<p>hello</p>",
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(InvalidFormDataError, match="Invalid value for select input 'decision'"):
|
||||
service.submit_form_by_token(
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
form_token="token",
|
||||
selected_action_id="submit",
|
||||
form_data={"decision": "hold"},
|
||||
)
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_file_list_item(sample_form_record, mock_session_factory) -> None:
|
||||
session_factory, _ = mock_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
inputs=[FileListInputConfig(output_variable_name="attachments", number_limits=2)],
|
||||
user_actions=[UserActionConfig(id="submit", title="Submit")],
|
||||
rendered_content="<p>hello</p>",
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
|
||||
with pytest.raises(
|
||||
InvalidFormDataError,
|
||||
match="Invalid value for file list input 'attachments': expected list of mappings",
|
||||
):
|
||||
service.submit_form_by_token(
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
form_token="token",
|
||||
selected_action_id="submit",
|
||||
form_data={"attachments": ["not-a-file"]},
|
||||
)
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file(sample_form_record, mock_session_factory, mocker) -> None:
|
||||
session_factory, _ = mock_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
inputs=[FileInputConfig(output_variable_name="attachment")],
|
||||
user_actions=[UserActionConfig(id="submit", title="Submit")],
|
||||
rendered_content="<p>hello</p>",
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
mocker.patch("services.human_input_service.build_from_mapping", side_effect=ValueError("Invalid upload file"))
|
||||
|
||||
with pytest.raises(InvalidFormDataError, match="Invalid value for file input 'attachment': Invalid upload file"):
|
||||
service.submit_form_by_token(
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
form_token="token",
|
||||
selected_action_id="submit",
|
||||
form_data={
|
||||
"attachment": {
|
||||
"transfer_method": "local_file",
|
||||
"upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e",
|
||||
"type": "document",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file_list(sample_form_record, mock_session_factory, mocker) -> None:
|
||||
session_factory, _ = mock_session_factory
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
form_content="hello",
|
||||
inputs=[FileListInputConfig(output_variable_name="attachments", number_limits=2)],
|
||||
user_actions=[UserActionConfig(id="submit", title="Submit")],
|
||||
rendered_content="<p>hello</p>",
|
||||
expiration_time=sample_form_record.expiration_time,
|
||||
)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
|
||||
service = HumanInputService(session_factory, form_repository=repo)
|
||||
mocker.patch("services.human_input_service.build_from_mappings", side_effect=ValueError("Invalid upload file"))
|
||||
|
||||
with pytest.raises(
|
||||
InvalidFormDataError,
|
||||
match="Invalid value for file list input 'attachments': Invalid upload file",
|
||||
):
|
||||
service.submit_form_by_token(
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
form_token="token",
|
||||
selected_action_id="submit",
|
||||
form_data={
|
||||
"attachments": [
|
||||
{
|
||||
"transfer_method": "local_file",
|
||||
"upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e",
|
||||
"type": "document",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user