test(api): add tests about file input file uploading api

This commit is contained in:
QuantumGhost 2026-05-07 10:52:54 +08:00
parent 37681bce8c
commit a0f8db5516
6 changed files with 849 additions and 4 deletions

View File

@ -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",
)

View File

@ -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."""

View File

@ -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"]

View File

@ -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():

View File

@ -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)

View File

@ -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()