Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions backend/api_v2/deployment_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from utils.constants import Account, CeleryQueue
from utils.local_context import StateStore
from workflow_manager.endpoint_v2.destination import DestinationConnector
from workflow_manager.endpoint_v2.result_cache_utils import ResultCacheUtils
from workflow_manager.endpoint_v2.source import SourceConnector
from workflow_manager.workflow_v2.dto import ExecutionResponse
from workflow_manager.workflow_v2.enums import ExecutionStatus
Expand Down Expand Up @@ -306,6 +307,26 @@ def execute_workflow(
)
).data

# Staging rejected every file, so there is nothing to dispatch. The worker
# short-circuits an empty file set without writing a status back, which
# would strand this execution in PENDING — terminalise it here instead.
if not hash_values_of_files:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 15] — The two fixes for the same scenario disagree on total_files

The worker sets total_files=0 (workers/api-deployment/tasks.py:230); this branch leaves it at the creation-time len(file_objs) (deployment_helper.py:241). An all-rejected run therefore lands COMPLETED with total_files=1 and zero file executions.

Cosmetic in the API response (which reads the result cache), but the executions list shows a completed run whose counts do not add up.

Suggested fix — have update_execution_completed zero the count, or accept a total_files argument, so both paths agree.

WorkflowExecutionServiceHelper.update_execution_completed(str(execution_id))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 3, 11] — Cleanup is skipped if this status write raises

update_execution_completed catches only WorkflowExecution.DoesNotExist; the update_execution it calls does select_for_update plus a save() and can raise OperationalError/DatabaseError. That propagates out of execute_workflow, so release_slot and delete_api_storage_dir on the next two lines never run: the org's rate-limit slot stays occupied and the staging dir is orphaned.

The sibling staging-failure path 25 lines above guards against precisely this, and there is a regression test pinning it:

# deployment_helper.py:285-291
try:
    WorkflowExecutionServiceHelper.update_execution_err(...)
except Exception:
    logger.exception(f"Failed to mark execution {execution_id} as ERROR")
# then release_slot + delete_api_storage_dir

backend/api_v2/tests/test_deployment_helper.py:75test_staging_failure_cleanup_survives_db_marking_error.

Suggested fix — same try/except Exception: logger.exception(...) wrapper around the update_execution_completed call.

Medium rather than High because the leak self-heals: _cleanup_expired_entries sweeps the zset by score (backend/api_v2/rate_limiter.py:44-47).

APIDeploymentRateLimiter.release_slot(api.organization, str(execution_id))
DestinationConnector.delete_api_storage_dir(
workflow_id=workflow_id, execution_id=execution_id
)
Comment on lines +313 to +318

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 3 · 8] — this branch can raise before its own cleanup runs

Failure mode. update_execution_completed catches only WorkflowExecution.DoesNotExist (execution.py:393-401). Any other DB failure — OperationalError, a statement or lock timeout on the select_for_update inside update_execution (models/execution.py:418-423), a deadlock, a dropped connection — propagates out of execute_workflow, so line 315 and lines 316-318 never run. The org's rate-limit slot stays held for the full 6h TTL and throttles every other API-deployment call for that org, the staging dir is never deleted, the row stays PENDING, and the caller gets a 500 with no execution id to poll.

Evidence. The sibling block immediately above (lines 289-308) deliberately isolates its DB write in an inner try/except with logger.exception so that cleanup always runs, and has a regression test pinning exactly that: test_staging_failure_cleanup_survives_db_marking_error (tests/test_deployment_helper.py:75-91). The new path copies the shape but not the guard, and has no equivalent test.

Suggested fix. Wrap the update_execution_completed call in its own try/except Exception: logger.exception(...) so release_slot and delete_api_storage_dir always execute, and add the mirror-image test.

Confidence: High.

return APIExecutionResponseSerializer(
ExecutionResponse(
workflow_id=workflow_id,
execution_id=execution_id,
execution_status=ExecutionStatus.COMPLETED.value,
result=ResultCacheUtils.get_api_results(
workflow_id=str(workflow_id), execution_id=str(execution_id)
),
)
).data
Comment on lines +319 to +328

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 3 · 10] — the response asserts COMPLETED whether or not the status write landed

Failure mode. There are three ways the call on line 314 returns normally without the row reaching COMPLETED:

  1. row missing — execution.py:399-401 logs and returns None;
  2. row vanished under the lock — models/execution.py:424-425, if locked is None: return, silent;
  3. row already terminal with a different value — models/execution.py:520-535 refuses, logs a warning, returns ([], False).

The return value is discarded and execution_status on line 323 is a hardcoded literal rather than the row's actual status. The API then answers COMPLETED while a follow-up GET /status/<execution_id> reads the DB and returns PENDING — the stranded-execution bug this PR exists to fix, now concealed behind a success response instead of being visible.

update_execution_completed was given a WorkflowExecution | None return type to carry exactly this signal, and no caller reads it.

Suggested fix. Bind the result: if it is None, or its status is not COMPLETED, log at error level and return the row's real status (or ERROR) rather than claiming COMPLETED.

Confidence: High.


try:
result = WorkflowHelper.execute_workflow_async(
workflow_id=workflow_id,
Expand Down
67 changes: 62 additions & 5 deletions backend/api_v2/tests/test_deployment_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ def collaborators():
mocks[
"WorkflowExecutionServiceHelper"
].create_workflow_execution.return_value = execution_row
mocks["SourceConnector"].add_input_file_to_api_storage.side_effect = (
RuntimeError("boom")
mocks["SourceConnector"].add_input_file_to_api_storage.side_effect = RuntimeError(
"boom"
)
yield mocks

Expand Down Expand Up @@ -74,9 +74,9 @@ def test_staging_failure_marks_execution_error(collaborators) -> None:

def test_staging_failure_cleanup_survives_db_marking_error(collaborators) -> None:
"""If marking the row ERROR itself raises, cleanup must still run (not propagate)."""
collaborators["WorkflowExecutionServiceHelper"].update_execution_err.side_effect = (
RuntimeError("db down")
)
collaborators[
"WorkflowExecutionServiceHelper"
].update_execution_err.side_effect = RuntimeError("db down")

# Must NOT raise — a failed error-marking should not break cleanup.
dh.DeploymentHelper.execute_workflow(
Expand All @@ -89,3 +89,60 @@ def test_staging_failure_cleanup_survives_db_marking_error(collaborators) -> Non
# Cleanup still runs even though error-marking raised.
collaborators["APIDeploymentRateLimiter"].release_slot.assert_called_once()
collaborators["DestinationConnector"].delete_api_storage_dir.assert_called_once()


@pytest.fixture
def staging_rejects_everything():
"""Patch execute_workflow's collaborators; staging returns no dispatchable files."""
with mock.patch.multiple(
dh,
WorkflowExecutionServiceHelper=mock.DEFAULT,
SourceConnector=mock.DEFAULT,
DestinationConnector=mock.DEFAULT,
APIDeploymentRateLimiter=mock.DEFAULT,
WorkflowHelper=mock.DEFAULT,
ResultCacheUtils=mock.DEFAULT,
Tag=mock.DEFAULT,
logger=mock.DEFAULT,
) as mocks:
execution_row = MagicMock()
execution_row.id = "exec-123"
mocks[
"WorkflowExecutionServiceHelper"
].create_workflow_execution.return_value = execution_row
mocks["SourceConnector"].add_input_file_to_api_storage.return_value = {}
mocks["ResultCacheUtils"].get_api_results.return_value = [
{"file": "evil.pdf", "status": "Failed", "error": "unsupported MIME type"}
]
yield mocks


def test_all_files_rejected_completes_without_dispatch(
staging_rejects_everything,
) -> None:
"""A request whose every file is rejected must reach a terminal status.

The worker short-circuits an empty file set without writing a status back, so
dispatching one strands the execution in PENDING and the caller polls forever.
"""
mocks = staging_rejects_everything
response = dh.DeploymentHelper.execute_workflow(
organization_name="org",
api=_api(),
file_objs=[],
timeout=-1,
)
Comment on lines +129 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 13] — the guard's predicate is unpinned; the original bug can be reintroduced with the suite green

Failure mode. This test passes file_objs=[] with SourceConnector fully mocked, so it exercises the zero-files-uploaded path, not the zero-files-staged path the guard exists for. Nothing in the suite asserts that a non-empty upload whose staging result is empty takes the short-circuit, and nothing asserts the short-circuit does not fire when staging returns files.

Evidence (mutants run against the branch, then reverted):

  • if not hash_values_of_files: -> if not file_objs: at deployment_helper.py:3133/3 pass. That mutant is the production bug verbatim: one HTML file uploaded, staging rejects it and returns {}, file_objs is non-empty, control falls through to execute_workflow_async, execution stranded in PENDING.
  • if not hash_values_of_files: -> if True: — the whole backend/api_v2/tests/ suite is identical to baseline (48 passed).
  • Control: deleting the update_execution_completed call does fail this test, so it pins the branch body, not the branch condition.

Also worth noting: assert response["result"][0]["status"] == "Failed" on line 148 reads back the fixture's own literal from line 115, so it proves the branch forwards the cache verbatim, not what source.py writes.

Suggested fix. Pass a non-empty file_objs (a bare MagicMock() suffices — with SourceConnector mocked, the only read is len(file_objs) at deployment_helper.py:243) so the two cases become distinguishable, and add a sibling test with add_input_file_to_api_storage.return_value = {"good.pdf": MagicMock()} asserting execute_workflow_async is called and update_execution_completed is not. Parametrising timeout over {-1, 10} closes the untested synchronous path at negligible cost.

Confidence: High (mutants executed).


# Nothing is dispatched...
mocks["WorkflowHelper"].execute_workflow_async.assert_not_called()
# ...the row is terminalised here instead of being left PENDING...
mocks[
"WorkflowExecutionServiceHelper"
].update_execution_completed.assert_called_once_with("exec-123")
# ...the slot and staging dir are released...
mocks["APIDeploymentRateLimiter"].release_slot.assert_called_once()
mocks["DestinationConnector"].delete_api_storage_dir.assert_called_once()
# ...and the caller still sees why each file failed.
assert response["execution_status"] == "COMPLETED"
assert response["result"][0]["file"] == "evil.pdf"
assert response["result"][0]["status"] == "Failed"
61 changes: 39 additions & 22 deletions backend/workflow_manager/endpoint_v2/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import logging
import os
import shutil
import uuid
from collections.abc import Collection
from hashlib import sha256
from io import BytesIO
Expand All @@ -25,7 +24,11 @@
SourceConstant,
SourceKey,
)
from workflow_manager.endpoint_v2.dto import FileHash, SourceConfig
from workflow_manager.endpoint_v2.dto import (
FileExecutionResult,
FileHash,
SourceConfig,
)
from workflow_manager.endpoint_v2.enums import AllowedFileTypes
from workflow_manager.endpoint_v2.exceptions import (
InvalidInputDirectory,
Expand All @@ -37,6 +40,7 @@
UnsupportedMimeTypeError,
)
from workflow_manager.endpoint_v2.models import WorkflowEndpoint
from workflow_manager.endpoint_v2.result_cache_utils import ResultCacheUtils
from workflow_manager.file_execution.models import WorkflowFileExecution
from workflow_manager.utils.workflow_log import WorkflowLog
from workflow_manager.workflow_v2.enums import ExecutionStatus
Expand Down Expand Up @@ -69,6 +73,8 @@ class SourceConnector(BaseConnector):
"""

READ_CHUNK_SIZE = 4194304 # Chunk size for reading files
# libmagic classifies from the leading bytes; reading more only costs memory.
MIME_DETECT_CHUNK_SIZE = 8192
Comment on lines +76 to +77

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] [Lens 3 · 1 · 16] — the 8 KiB sniff window rejects legacy Office uploads that work today

Failure mode. libmagic resolves an OLE2 compound file through a directory sector that normally sits near the end of the file. Given only these 8192 bytes it falls back to application/x-ole-storage, which is not in AllowedFileTypes (the list carries application/msword, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/CDFV2 — enums.py:19,21,24,28). Every .doc/.xls/.ppt larger than the sample is therefore rejected as unsupported, on both callers of add_input_file_to_api_storage: the API-deployment path (deployment_helper.py:280) and the UI workflow execute endpoint (workflow_v2/views.py:263). Before this PR the caller-declared type was accepted and the file processed.

Evidence. Reproduced twice independently, on a real 248 KB .ppt and on LibreOffice-produced .doc/.xls, with libmagic 5.45 and with 5.46 inside the shipped backend image:

sample4.ppt   8KiB -> application/x-ole-storage   64KiB -> application/x-ole-storage   full -> application/vnd.ms-powerpoint
doc500.doc    8KiB -> application/x-ole-storage                                        full -> application/msword
big.xls       8KiB -> application/x-ole-storage                                        full -> application/vnd.ms-excel

Widening to 64 KiB does not fix it. The same root cause degrades a .docx whose [Content_Types].xml compresses past the window to application/zip, also not allow-listed.

Every other sniff site in this codebase reads 4 MiB (source.py:75, workers/shared/workflow/execution/service.py:1220), which is why these files pass the existing downstream check and fail only the new one — 8 KiB is 512x narrower and introduced here.

This also makes the comment on line 76 false, and it is the stated justification for the constant. The PR description's "files that were already processing successfully are unaffected — they sniff to their real type, which is in the allow-list" does not hold for this class.

Suggested fix. Sniff the full staged object (magic.from_file), or treat application/x-ole-storage and application/zip as inconclusive rather than unsupported. Either way the regression test needs a fixture larger than the window — the current ones are 45 bytes (tests/test_api_storage_mime_validation.py:27-28), which is why neither CI nor the dev-env run surfaced this.

Confidence: High.


def __init__(
self,
Expand Down Expand Up @@ -1187,6 +1193,22 @@ def load_file(self, input_file_path: str) -> tuple[str, BytesIO]:

return os.path.basename(input_file_path), file_stream

@classmethod
def _detect_uploaded_file_mime_type(cls, file: UploadedFile) -> str:
"""Detect an uploaded file's MIME type from its own bytes.

The multipart Content-Type is supplied by the caller and never verified,
so it cannot be used to decide what is allowed into API storage.
"""
sample = file.read(cls.MIME_DETECT_CHUNK_SIZE)
file.seek(0)
if not sample:
# libmagic reports "application/x-empty" here, which would reject the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 13] — The two deliberate new branches are the two without tests

This empty-upload branch exists specifically to preserve the downstream EmptyFileError path — it is the one branch of _detect_uploaded_file_mime_type reachable only with zero bytes, and nothing pins it. Delete it and the suite stays green while empty uploads start being relabelled as unsupported-type failures, which is the exact outcome the comment says to avoid.

Same for workers/api-deployment/tasks.py:225-231: nothing asserts the worker now persists the status, which is the whole point of that hunk.

Suggested fix — two assertions:

  • _stage([_upload("empty.pdf", b"", "application/pdf")]) returns the file staged with mime_type == "application/octet-stream".
  • A worker test asserting update_workflow_execution_status is called with COMPLETED on the empty short-circuit.

Noted in the PR's favour: the existing 5 tests were verified to discriminate (reverting the detection line fails 4), which is more than most PRs do.

# file as an unsupported type. An empty upload is a distinct failure
# and is reported as such once staging hands off, so let it pass.
return AllowedFileTypes.OCTET_STREAM.value
return magic.from_buffer(sample, mime=True)

@classmethod
def add_input_file_to_api_storage(
cls,
Expand Down Expand Up @@ -1228,30 +1250,25 @@ def add_input_file_to_api_storage(
file_name = file.name
destination_path = os.path.join(api_storage_dir, file_name)

mime_type = file.content_type
mime_type = cls._detect_uploaded_file_mime_type(file)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 1, 7] — The PR body's compatibility claim about zips is wrong

The description states: "Mislabelled binaries that libmagic reports as octet-stream (e.g. plain zips) still pass, as before". libmagic does not report zips as octet-stream. Measured against the pinned python-magic:

bytes sniffed in AllowedFileTypes?
plain zip application/zip
RTF text/rtf
HEIC image/heic
BMP header application/octet-stream

Any upload of those that previously reached the bucket by declaring application/octet-stream or by omitting Content-Type is now a hard Failed (AllowedFileTypes, backend/workflow_manager/endpoint_v2/enums.py:9-35).

That may well be the intent — the check itself is correct and this is not a code defect. But "Can this PR break any existing features" currently asserts the opposite, so whoever approves this is approving an understated blast radius.

Suggested fix — correct the claim in the PR body. If any tenant is known to push zips/RTF through API deployments, that is a rollout question worth answering before merge rather than after.

Confidence: High on the libmagic behaviour (measured, not recalled). Medium on real-world impact — depends on tenant traffic I cannot see.

logger.info(f"Detected MIME type: {mime_type} for file {file_name}")
if not mime_type:
logger.info(
f"MIME type not found for file {file_name}, using default MIME type: {AllowedFileTypes.OCTET_STREAM.value}"
)
mime_type = AllowedFileTypes.OCTET_STREAM.value

if not AllowedFileTypes.is_allowed(mime_type):
log_message = f"Skipping file '{file_name}' to stage due to unsupported MIME type '{mime_type}'"
workflow_log.log_info(logger=logger, message=log_message)
# Generate a clearly marked temporary hash to avoid reading the file content
# Helps to prevent duplicate entries in file executions
fake_hash = f"temp-hash-{uuid.uuid4().hex}"
file_hash = FileHash(
file_path=destination_path,
source_connection_type=connection_type,
file_name=file_name,
file_hash=fake_hash,
is_executed=True,
file_size=file.size,
mime_type=mime_type,
log_message = (
f"Rejecting file '{file_name}' with unsupported MIME type "
f"'{mime_type}'"
)
workflow_log.log_error(logger=logger, message=log_message)
# Rejected files are never dispatched, so nothing downstream will
# report on them - surface the failure in the API response here.
ResultCacheUtils.update_api_results(
workflow_id=workflow_id,
execution_id=execution_id,
api_result=FileExecutionResult(
file=file_name,
error=log_message,
),
Comment on lines +1262 to +1270

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 5 · 10] — a rejected file leaves no durable record, and an all-rejected run is stored as a clean success

Failure mode. Before this change a rejected file produced a FileHash, was dispatched, and the worker's own libmagic check (workers/shared/workflow/execution/service.py:1223-1229) created a real WorkflowFileExecution row. After this change it produces no FileHash, no WorkflowFileExecution, no file-history row. The rejection exists only as an entry in the Redis list api_results:{workflow_id}:{execution_id}, which is deleted the first time anyone polls /status (workflow_helper.py:451-453), expires after EXECUTION_RESULT_TTL_SECONDS (3h default), and is gone on any eviction or restart. After any of those, nothing in Postgres can answer "why was my file not processed?".

Compounding it on the all-rejected path: deployment_helper.py:313-318 writes only status=COMPLETED. The row keeps total_files = len(file_objs) from line 243 while failed_files and successful_files stay NULL (models/execution.py:191-208, nullable, no default). is_failure_run is is_failure(status) or (failed_files or 0) > 0 (unstract/core/.../data_models.py:663), so COMPLETED + NULL reads as a success — the response body says every file Failed while the execution row says N files, zero failures. This is the hazard already written up at internal_views.py:546-550 ("a terminal status with failed_files=None ... silently bypasses notify_on_failures subscribers"). Run history is affected too: get_last_run_statuses derives PARTIAL_SUCCESS from these counters (models/execution.py:622-636).

Separately, the early return never reaches PipelineUtils.update_pipeline_status, the only dispatcher of API-deployment notifications (pipeline_utils.py:58 -> APIDeploymentUtils.send_notification), so an all-rejected request now sends no webhook at all where the dispatched-and-failed run previously alerted.

Note also that the worker-side check already raises UnsupportedMimeTypeError naming the file and the MIME type, which softens the PR description's premise that an unsupported file today "fails at extraction with an error that does not name the real cause".

Suggested fix. Write the aggregates alongside the status (failed_files=len(file_objs), successful_files=0), and keep a persisted per-file record for a rejected file — a WorkflowFileExecution row in terminal ERROR carrying the real MIME type — so the rejection is auditable after the cache entry is gone. If cache-only is deliberate, it is worth stating in the PR description as a support/audit trade-off.

Confidence: High.

)
file_hashes.update({file_name: file_hash})
continue

file_system = FileSystem(FileStorageType.API_EXECUTION)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""MIME validation for files staged into API storage.

``SourceConnector.add_input_file_to_api_storage`` is the single funnel through
which API-deployment uploads reach the API storage bucket, so an unsupported
file has to be rejected here or it reaches the extraction step and fails there
with an error that does not name the real cause.

Unit tests: the real classmethod runs with its DB/storage-touching
collaborators patched on the imported module, so no database is needed. MIME
detection itself is deliberately *not* patched — sniffing the bytes with
libmagic is the behaviour under test.
"""

from unittest import mock
from unittest.mock import MagicMock

import pytest
from django.core.files.uploadedfile import SimpleUploadedFile

import workflow_manager.endpoint_v2.source as src_mod
from workflow_manager.endpoint_v2.constants import ApiDeploymentResultStatus
from workflow_manager.endpoint_v2.source import SourceConnector

# Bytes chosen from what libmagic actually reports (verified against the pinned
# python-magic): a PDF header sniffs application/pdf, an HTML document sniffs
# text/html, which is absent from AllowedFileTypes.
PDF_BYTES = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n"
HTML_BYTES = b"<!DOCTYPE html><html><body>hello</body></html>"


API_STORAGE_DIR = "/api-storage/exec-1"


@pytest.fixture
def collaborators():
"""Patch everything the staging loop touches except MIME detection."""
with (
mock.patch.multiple(
src_mod,
UserContext=mock.DEFAULT,
WorkflowLog=mock.DEFAULT,
Workflow=mock.DEFAULT,
FileSystem=mock.DEFAULT,
FileHistoryHelper=mock.DEFAULT,
ResultCacheUtils=mock.DEFAULT,
) as mocks,
mock.patch.object(
SourceConnector,
"get_api_storage_dir_path",
return_value=API_STORAGE_DIR,
),
):
storage = MagicMock()
mocks["FileSystem"].return_value.get_file_storage.return_value = storage
mocks["storage"] = storage
yield mocks


def _upload(name: str, content: bytes, declared: str) -> SimpleUploadedFile:
"""An uploaded file whose declared Content-Type may not match its bytes."""
return SimpleUploadedFile(name, content, content_type=declared)


def _stage(files):
return SourceConnector.add_input_file_to_api_storage(
pipeline_id="pipe-1",
workflow_id="wf-1",
execution_id="exec-1",
file_objs=files,
)


def _staged_names(storage: MagicMock) -> set[str]:
"""File names that actually had bytes written to API storage."""
return {
call.kwargs["path"].rsplit("/", 1)[-1] for call in storage.write.call_args_list
}


def test_supported_file_is_staged(collaborators) -> None:
"""A real PDF is staged and returned for dispatch."""
result = _stage([_upload("doc.pdf", PDF_BYTES, "application/pdf")])

assert set(result) == {"doc.pdf"}
assert result["doc.pdf"].mime_type == "application/pdf"
assert _staged_names(collaborators["storage"]) == {"doc.pdf"}


def test_unsupported_bytes_rejected_despite_supported_declared_type(
collaborators,
) -> None:
"""The declared Content-Type must not decide what reaches the bucket.

An HTML file announced as application/pdf satisfies any header-based check,
so only sniffing the bytes keeps it out.
"""
result = _stage([_upload("evil.pdf", HTML_BYTES, "application/pdf")])

# Never dispatched...
assert result == {}
# ...and never written to the bucket.
collaborators["storage"].write.assert_not_called()


def test_rejection_is_reported_to_the_caller(collaborators) -> None:
"""A rejected file gets its own failed entry in the API response."""
_stage([_upload("evil.pdf", HTML_BYTES, "application/pdf")])

collaborators["ResultCacheUtils"].update_api_results.assert_called_once()
api_result = collaborators["ResultCacheUtils"].update_api_results.call_args.kwargs[
"api_result"
]
assert api_result.file == "evil.pdf"
# The message has to name the offending type, not a downstream symptom.
assert "text/html" in api_result.error
assert api_result.status == ApiDeploymentResultStatus.FAILED


def test_missing_declared_type_falls_back_to_sniffed_type(collaborators) -> None:
"""A supported file with no declared Content-Type is still staged.

The recorded type comes from the bytes, so an absent header neither blocks
the file nor degrades it to application/octet-stream.
"""
result = _stage([_upload("doc.pdf", PDF_BYTES, "")])

assert result["doc.pdf"].mime_type == "application/pdf"


def test_supported_files_survive_a_rejected_sibling(collaborators) -> None:
"""One bad file does not fail the whole request."""
result = _stage(
[
_upload("good.pdf", PDF_BYTES, "application/pdf"),
_upload("evil.pdf", HTML_BYTES, "application/pdf"),
]
)

assert set(result) == {"good.pdf"}
assert _staged_names(collaborators["storage"]) == {"good.pdf"}
13 changes: 13 additions & 0 deletions backend/workflow_manager/workflow_v2/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,19 @@ def update_execution_err(execution_id: str, err_msg: str = "") -> WorkflowExecut
except WorkflowExecution.DoesNotExist:
logger.error(f"execution doesn't exist {execution_id}")

@staticmethod
def update_execution_completed(execution_id: str) -> WorkflowExecution | None:
"""Terminalise an execution that finished without any work to dispatch."""
try:
execution = WorkflowExecution.objects.get(pk=execution_id)
# Same reason as update_execution_err: the model method owns the
# terminal-one-way guard, so this cannot revert an already-final row.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 16] — This comment asserts a guard that does not hold on the Celery transport

"the model method owns the terminal-one-way guard, so this cannot revert an already-final row" is only true on the PG path.

update_execution routes on queue_message_id: when it is NULL (the Celery/legacy transport, still the default), _apply_legacy_update runs — and it sets self.status = status.value unconditionally, with no guard at all (models/execution.py:455-471). Only _apply_guarded_status (models/execution.py:506-517) guards.

Both sibling comments in this same file carry the qualifier this one drops:

  • execution.py:176-178 — "…terminal-one-way guard (atomic select_for_update, PG-scoped, field-scoped writes)"
  • execution.py:382-384 (update_execution_err) — "…so a late error handler can't revert a PG execution the callback already finalized"

A maintainer trusting this comment and reusing update_execution_completed in a late completion callback would overwrite an already-ERROR execution with COMPLETED.

Suggested fix — comment-only; restore the scoping, e.g. "…so a PG execution the callback already finalized cannot be reverted."

No live bug at the current call site — the row is freshly created and PENDING — which is why this is High and not Critical.

execution.update_execution(status=ExecutionStatus.COMPLETED)
return execution
except WorkflowExecution.DoesNotExist:
logger.error(f"execution doesn't exist {execution_id}")
return None

@staticmethod
def update_execution_task(execution_id: str, task_id: str) -> None:
try:
Expand Down
Loading
Loading