From a0f8db551602b8e1e65a7914e73ad0407899159e Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Thu, 7 May 2026 10:52:54 +0800 Subject: [PATCH] test(api): add tests about file input file uploading api --- .../web/test_human_input_file_upload.py | 186 ++++++++++ .../controllers/web/test_human_input_form.py | 30 ++ .../nodes/human_input/test_entities.py | 115 +++++- .../test_human_input_form_filled_event.py | 27 +- .../test_human_input_file_upload_service.py | 144 +++++++ .../services/test_human_input_service.py | 351 ++++++++++++++++++ 6 files changed, 849 insertions(+), 4 deletions(-) create mode 100644 api/tests/unit_tests/controllers/web/test_human_input_file_upload.py create mode 100644 api/tests/unit_tests/services/test_human_input_file_upload_service.py diff --git a/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py b/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py new file mode 100644 index 0000000000..66747b89d6 --- /dev/null +++ b/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py @@ -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", + ) diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index a1dbc80b20..d51359e395 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -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.""" diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index 8499fdfbe4..fc7cdd64f9 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -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"] diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index e4fb5954f6..71c1f113a2 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -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(): diff --git a/api/tests/unit_tests/services/test_human_input_file_upload_service.py b/api/tests/unit_tests/services/test_human_input_file_upload_service.py new file mode 100644 index 0000000000..11819ab520 --- /dev/null +++ b/api/tests/unit_tests/services/test_human_input_file_upload_service.py @@ -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) diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index af0e793261..1c686ce838 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -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": "

Pick one and upload files

", + "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": "

Validate form data

", + "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="

hello

", + 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="

hello

", + 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="

hello

", + 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="

hello

", + 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="

hello

", + 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()