fix: validate workflow status filter to prevent 500 on invalid enum value (#450)

* Validate workflow status filter to prevent 500 on invalid enum value

The /workflow/fetch and /workflow/summary endpoints accepted a free-form
status query param and passed it straight into a query that casts to the
workflow_status PG enum (active/archived). Any other value — e.g. an
external caller passing 'published' (a workflow_definitions version state,
not a workflow status) — failed deep in Postgres as
InvalidTextRepresentationError, surfacing as an unhandled HTTP 500.

Add _validate_status_filter() to reject values outside WorkflowStatus with
a clean 422 before any DB query, for both the single and comma-separated
paths. Add route tests covering invalid, valid-single, comma-separated, and
mixed valid/invalid cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: add tests

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-06-18 08:39:59 +05:30 committed by GitHub
parent 9a1b980f91
commit d2cda85b78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 186 additions and 26 deletions

View file

@ -2,6 +2,7 @@ from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
@ -50,3 +51,99 @@ def test_workflow_fetch_list_includes_workflow_uuid():
"workflow_uuid": workflow.workflow_uuid,
}
]
def test_workflow_fetch_invalid_status_returns_422_without_db_query():
"""A status outside the workflow_status enum (e.g. 'published') must fail
as a clean 422 instead of a 500 from the Postgres enum cast."""
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock()
mock_db.get_workflow_run_counts = AsyncMock()
response = client.get("/workflow/fetch?status=published")
assert response.status_code == 422
assert "published" in response.json()["detail"]
# The invalid value must never reach the database layer.
mock_db.get_all_workflows_for_listing.assert_not_called()
def test_workflow_fetch_valid_single_status_passes_through():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock(return_value=[])
mock_db.get_workflow_run_counts = AsyncMock(return_value={})
response = client.get("/workflow/fetch?status=active")
assert response.status_code == 200
mock_db.get_all_workflows_for_listing.assert_awaited_once_with(
organization_id=11, status="active"
)
def test_workflow_fetch_comma_separated_status_queries_each_value():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock(return_value=[])
mock_db.get_workflow_run_counts = AsyncMock(return_value={})
response = client.get("/workflow/fetch?status=active,archived")
assert response.status_code == 200
assert mock_db.get_all_workflows_for_listing.await_count == 2
statuses = {
call.kwargs["status"]
for call in mock_db.get_all_workflows_for_listing.await_args_list
}
assert statuses == {"active", "archived"}
def test_workflow_fetch_mixed_valid_and_invalid_status_returns_422():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock()
mock_db.get_workflow_run_counts = AsyncMock()
response = client.get("/workflow/fetch?status=active,published")
assert response.status_code == 422
mock_db.get_all_workflows_for_listing.assert_not_called()
@pytest.mark.parametrize("status", [" ", ",", "active,,archived"])
def test_workflow_fetch_blank_status_token_returns_422_without_db_query(status: str):
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock()
mock_db.get_workflow_run_counts = AsyncMock()
response = client.get("/workflow/fetch", params={"status": status})
assert response.status_code == 422
assert "<empty>" in response.json()["detail"]
mock_db.get_all_workflows_for_listing.assert_not_called()
def test_workflow_summary_blank_status_token_returns_422_without_db_query():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows = AsyncMock()
response = client.get("/workflow/summary", params={"status": ","})
assert response.status_code == 422
mock_db.get_all_workflows.assert_not_called()

View file

@ -5,6 +5,7 @@ import pytest
from api.services import workflow_run_billing as workflow_run_billing_mod
from api.services.workflow_run_billing import (
_is_usage_not_ready_error,
report_completed_workflow_run_platform_usage,
report_workflow_run_platform_usage,
)
@ -24,6 +25,16 @@ def _make_workflow_run():
)
def test_is_usage_not_ready_error_detects_mps_409():
exc = Exception("Failed to report platform usage")
exc.response = SimpleNamespace(
status_code=409,
text='{"detail":"usage_not_ready"}',
)
assert _is_usage_not_ready_error(exc) is True
@pytest.mark.asyncio
async def test_report_workflow_run_platform_usage_reports_hosted_completion(
monkeypatch,