-
Notifications
You must be signed in to change notification settings - Fork 715
UN-3016 [FIX] Record why an execution failed; stop dispatching skipped files #2256
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
89a5256
c3aa791
9e1adc4
af64650
1ceb723
e15e3e2
2c95b6a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| """UN-3016: a file rejected for an unsupported MIME type must not reach a worker. | ||
|
|
||
| ``SourceConnector.add_input_file_to_api_storage`` deliberately does not stage | ||
| the bytes of a file whose MIME type is not allowed. It used to return that file | ||
| anyway, with ``is_executed=True`` and a placeholder hash; nothing downstream | ||
| filters on ``is_executed``, so a worker picked the file up, failed on the file | ||
| that was never written, and the whole execution died with an opaque | ||
| "Execution: <path>; Destination: <path>" message. These tests pin the fix: the | ||
| rejected file is excluded from the returned mapping, the supported files in the | ||
| same request still go through, and an all-rejected request fails loudly with a | ||
| message that names what was rejected. | ||
|
|
||
| DB-free by construction: the ORM boundary (``Workflow.objects.get``) and the | ||
| file storage are patched, so nothing here needs Postgres. Files are real | ||
| ``SimpleUploadedFile`` objects rather than mocks — a mock's ``content_type`` is | ||
| not in ``AllowedFileTypes``, so a mocked "supported" file would silently take | ||
| the skip branch and the test would pass for the wrong reason. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import django | ||
| import pytest | ||
| from django.apps import apps | ||
|
|
||
| os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") | ||
| if not apps.ready: | ||
| django.setup() | ||
|
|
||
| from django.core.files.uploadedfile import SimpleUploadedFile # noqa: E402 | ||
| from workflow_manager.endpoint_v2.exceptions import ( # noqa: E402 | ||
| UnsupportedMimeTypeError, | ||
| ) | ||
| from workflow_manager.endpoint_v2.source import SourceConnector # noqa: E402 | ||
|
|
||
| SUPPORTED_MIME = "application/pdf" | ||
| UNSUPPORTED_MIME = "application/x-msdownload" | ||
|
|
||
|
|
||
| def _upload(name: str, content_type: str) -> SimpleUploadedFile: | ||
| """A real uploaded file: gives .name, .content_type, .size and .chunks().""" | ||
| return SimpleUploadedFile(name, b"some bytes", content_type=content_type) | ||
|
|
||
|
|
||
| def _stage(file_objs: list[SimpleUploadedFile]) -> dict: | ||
| """Call the staging helper with every external boundary patched out.""" | ||
| with ( | ||
| patch("workflow_manager.endpoint_v2.source.UserContext") as mock_user_context, | ||
| patch("workflow_manager.endpoint_v2.source.WorkflowLog"), | ||
| patch("workflow_manager.endpoint_v2.source.Workflow") as mock_workflow, | ||
| patch("workflow_manager.endpoint_v2.source.FileSystem"), | ||
| patch.object( | ||
| SourceConnector, | ||
| "get_api_storage_dir_path", | ||
| return_value="unstract/api/org/exec-1", | ||
| ), | ||
| ): | ||
| mock_user_context.get_organization_identifier.return_value = "org" | ||
| mock_workflow.objects.get.return_value = MagicMock() | ||
| return SourceConnector.add_input_file_to_api_storage( | ||
| pipeline_id="pipeline-1", | ||
| workflow_id="workflow-1", | ||
| execution_id="exec-1", | ||
| file_objs=file_objs, | ||
| ) | ||
|
|
||
|
|
||
| def test_partial_skip_returns_only_the_supported_files(): | ||
| """The core fix: a rejected file is absent from the returned mapping, and | ||
| the supported file alongside it is unaffected. | ||
|
|
||
| Also pins that a partial skip does NOT raise — rejecting some files while | ||
| others are runnable proceeds with what can be run (UN-4055 tracks whether | ||
| that should stay the behaviour). | ||
| """ | ||
| file_hashes = _stage( | ||
| [ | ||
| _upload("good.pdf", SUPPORTED_MIME), | ||
| _upload("bad.exe", UNSUPPORTED_MIME), | ||
| ] | ||
| ) | ||
|
|
||
| assert "bad.exe" not in file_hashes | ||
| assert "good.pdf" in file_hashes | ||
| assert len(file_hashes) == 1 | ||
| assert file_hashes["good.pdf"].mime_type == SUPPORTED_MIME | ||
| assert file_hashes["good.pdf"].file_name == "good.pdf" | ||
|
|
||
|
|
||
| def test_supported_file_is_staged_with_a_real_hash(): | ||
| """An accepted file's FileHash carries the sha256 of the bytes that were | ||
| staged. | ||
|
|
||
| Characterisation only: this held before the fix too, and no mutation of the | ||
| fix makes it fail. It is here to document what a returned entry looks like, | ||
| which is what makes the rejected file's absence elsewhere meaningful. | ||
| """ | ||
| file_hashes = _stage([_upload("good.pdf", SUPPORTED_MIME)]) | ||
|
|
||
| assert set(file_hashes) == {"good.pdf"} | ||
| file_hash = file_hashes["good.pdf"].file_hash | ||
| assert len(file_hash) == 64 | ||
| assert all(c in "0123456789abcdef" for c in file_hash) | ||
|
|
||
|
|
||
| def test_total_skip_raises_naming_the_skipped_files(): | ||
| """When nothing survives the filter there is nothing to run: fail with the | ||
| reason instead of dispatching an empty execution that reports success. | ||
| """ | ||
| with pytest.raises(UnsupportedMimeTypeError) as excinfo: | ||
|
Check warning on line 113 in backend/workflow_manager/endpoint_v2/tests/test_un3016_unsupported_mime_skip.py
|
||
| _stage( | ||
| [ | ||
| _upload("bad.exe", UNSUPPORTED_MIME), | ||
| _upload("worse.dll", UNSUPPORTED_MIME), | ||
| ] | ||
| ) | ||
|
|
||
| message = str(excinfo.value) | ||
| assert "bad.exe" in message | ||
| assert "worse.dll" in message | ||
| assert UNSUPPORTED_MIME in message | ||
|
|
||
|
|
||
| def test_no_files_at_all_returns_empty_without_raising(): | ||
| """An empty request has no skipped files, so it is not an unsupported-type | ||
| failure — the raise is guarded on there being something skipped. | ||
| """ | ||
| assert _stage([]) == {} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| """UN-3016: a staging failure must clean up the API storage directory. | ||
|
|
||
| ``WorkflowViewSet.execute`` stages uploaded files before running the workflow. | ||
| Staging can now fail part-way — ``add_input_file_to_api_storage`` raises | ||
| ``UnsupportedMimeTypeError`` when every uploaded file is rejected, and it may | ||
| have written some files before reaching that point. The staging call therefore | ||
| sits inside the ``try`` whose handler calls ``delete_api_storage_dir``, and that | ||
| handler's guard (``has_uploads``) is exactly the condition under which staging | ||
| ran at all. These tests pin both halves: cleanup happens when staging fails, | ||
| and cleanup is not attempted for a request that never staged anything. | ||
|
|
||
| DB-free: the serializer, the workflow lookup and both connectors are patched, | ||
| so ``execute`` is exercised as pure control flow. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| from contextlib import ExitStack | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import django | ||
| import pytest | ||
| from django.apps import apps | ||
|
|
||
| os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.test") | ||
| if not apps.ready: | ||
| django.setup() | ||
|
|
||
| from workflow_manager.endpoint_v2.exceptions import ( # noqa: E402 | ||
| UnsupportedMimeTypeError, | ||
| ) | ||
| from workflow_manager.workflow_v2.views import WorkflowViewSet # noqa: E402 | ||
|
|
||
| WORKFLOW_ID = "workflow-1" | ||
| EXECUTION_ID = "exec-1" | ||
|
|
||
| VIEWS = "workflow_manager.workflow_v2.views" | ||
|
|
||
|
|
||
| def _request(with_files: bool) -> MagicMock: | ||
| request = MagicMock() | ||
| request.FILES.getlist.return_value = [MagicMock()] if with_files else [] | ||
| return request | ||
|
|
||
|
|
||
| def _patched_serializer(stack): | ||
| """Patch the serializer so execute() gets ids without parsing a payload.""" | ||
| serializer_cls = stack.enter_context(patch(f"{VIEWS}.ExecuteWorkflowSerializer")) | ||
| serializer = serializer_cls.return_value | ||
| serializer.get_workflow_id.return_value = WORKFLOW_ID | ||
| serializer.get_execution_id.return_value = EXECUTION_ID | ||
| serializer.get_execution_action.return_value = None | ||
| return serializer | ||
|
|
||
|
|
||
| def test_staging_failure_deletes_the_api_storage_dir(): | ||
| """A staging failure leaves already-written files behind unless the handler | ||
| cleans up, so the failure must reach ``delete_api_storage_dir``. | ||
| """ | ||
| with ExitStack() as stack: | ||
| _patched_serializer(stack) | ||
| source = stack.enter_context(patch(f"{VIEWS}.SourceConnector")) | ||
| destination = stack.enter_context(patch(f"{VIEWS}.DestinationConnector")) | ||
| source.add_input_file_to_api_storage.side_effect = UnsupportedMimeTypeError( | ||
| "No files could be processed. Unsupported file type(s): 'bad.exe'" | ||
| ) | ||
|
|
||
| with pytest.raises(UnsupportedMimeTypeError): | ||
|
Check warning on line 69 in backend/workflow_manager/workflow_v2/tests/test_un3016_execute_staging_cleanup.py
|
||
| WorkflowViewSet().execute(_request(with_files=True)) | ||
|
|
||
| destination.delete_api_storage_dir.assert_called_once_with( | ||
| workflow_id=WORKFLOW_ID, execution_id=EXECUTION_ID | ||
| ) | ||
|
|
||
|
|
||
| def test_no_cleanup_when_the_request_staged_nothing(): | ||
| """A request with no uploads never created a storage dir; a later failure | ||
| must not try to delete one. | ||
| """ | ||
| with ExitStack() as stack: | ||
| _patched_serializer(stack) | ||
| stack.enter_context(patch(f"{VIEWS}.SourceConnector")) | ||
| destination = stack.enter_context(patch(f"{VIEWS}.DestinationConnector")) | ||
| stack.enter_context( | ||
| patch.object( | ||
| WorkflowViewSet, | ||
| "get_workflow_by_id", | ||
| side_effect=RuntimeError("boom"), | ||
| ) | ||
| ) | ||
|
|
||
| with pytest.raises(RuntimeError): | ||
|
Check warning on line 93 in backend/workflow_manager/workflow_v2/tests/test_un3016_execute_staging_cleanup.py
|
||
| WorkflowViewSet().execute(_request(with_files=False)) | ||
|
|
||
| destination.delete_api_storage_dir.assert_not_called() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High] [Lens 13 — Testing] — The staging move is unpinned against dropping the staged hashes
Both tests in
test_un3016_execute_staging_cleanup.py(:57,:77) raise beforeexecute_workflowis reached, so nothing asserts the staged hashes still flow through.Losing this assignment dispatches an execution with
hash_values_of_files={}— the run reports success having processed zero uploaded files. A silent correctness failure on the primary upload flow.Mutation executed. Changing
:269to drop only the assignment → 2 passed. No other test exercisesWorkflowViewSet.execute().Fix: a third test asserting
execute_workflow.call_args.kwargs["hash_values_of_files"]is the sentinel returned by staging, and thatuse_file_historyisFalse.Confidence: High (mutation executed).