diff --git a/moto/swf/exceptions.py b/moto/swf/exceptions.py index 5f3daf108..4d0a9dede 100644 --- a/moto/swf/exceptions.py +++ b/moto/swf/exceptions.py @@ -87,3 +87,35 @@ class SWFValidationException(SWFClientError): message, "com.amazon.coral.validate#ValidationException" ) + + +class SWFDecisionValidationException(SWFClientError): + def __init__(self, problems): + # messages + messages = [] + for pb in problems: + if pb["type"] == "null_value": + messages.append( + "Value null at '%(where)s' failed to satisfy constraint: "\ + "Member must not be null" % pb + ) + elif pb["type"] == "bad_decision_type": + messages.append( + "Value '%(value)s' at '%(where)s' failed to satisfy constraint: " \ + "Member must satisfy enum value set: " \ + "[%(possible_values)s]" % pb + ) + else: + raise ValueError( + "Unhandled decision constraint type: {}".format(pb["type"]) + ) + # prefix + count = len(problems) + if count < 2: + prefix = "{} validation error detected:" + else: + prefix = "{} validation errors detected:" + super(SWFDecisionValidationException, self).__init__( + prefix.format(count) + "; ".join(messages), + "com.amazon.coral.validate#ValidationException" + ) diff --git a/moto/swf/models/workflow_execution.py b/moto/swf/models/workflow_execution.py index fe35b2fdb..a9530ef70 100644 --- a/moto/swf/models/workflow_execution.py +++ b/moto/swf/models/workflow_execution.py @@ -3,12 +3,36 @@ import uuid from moto.core.utils import camelcase_to_underscores -from ..exceptions import SWFDefaultUndefinedFault +from ..exceptions import ( + SWFDefaultUndefinedFault, + SWFValidationException, + SWFDecisionValidationException, +) from .decision_task import DecisionTask from .history_event import HistoryEvent +# TODO: extract decision related logic into a Decision class class WorkflowExecution(object): + + # NB: the list is ordered exactly as in SWF validation exceptions so we can + # mimic error messages closely ; don't reorder it without checking SWF. + KNOWN_DECISION_TYPES = [ + "CompleteWorkflowExecution", + "StartTimer", + "RequestCancelExternalWorkflowExecution", + "SignalExternalWorkflowExecution", + "CancelTimer", + "RecordMarker", + "ScheduleActivityTask", + "ContinueAsNewWorkflowExecution", + "ScheduleLambdaFunction", + "FailWorkflowExecution", + "RequestCancelActivityTask", + "StartChildWorkflowExecution", + "CancelWorkflowExecution" + ] + def __init__(self, workflow_type, workflow_id, **kwargs): self.workflow_type = workflow_type self.workflow_id = workflow_id @@ -154,6 +178,7 @@ class WorkflowExecution(object): def complete_decision_task(self, task_token, decisions=None, execution_context=None): # TODO: check if decision can really complete in case of malformed "decisions" + self.validate_decisions(decisions) dt = self._find_decision_task(task_token) evt = self._add_event( "DecisionTaskCompleted", @@ -164,6 +189,48 @@ class WorkflowExecution(object): dt.complete() self.handle_decisions(evt.event_id, decisions) + def validate_decisions(self, decisions): + """ + Performs some basic validations on decisions. The real SWF service + seems to break early and *not* process any decision if there's a + validation problem, such as a malformed decision for instance. I didn't + find an explicit documentation for that though, so criticisms welcome. + """ + if not decisions: + return + + problems = [] + + # check close decision is last + # TODO: see what happens on real SWF service if we ask for 2 close decisions + for dcs in decisions[:-1]: + close_decision_types = [ + "CompleteWorkflowExecution", + "FailWorkflowExecution", + "CancelWorkflowExecution", + ] + if dcs["decisionType"] in close_decision_types: + raise SWFValidationException( + "Close must be last decision in list" + ) + + decision_number = 0 + for dcs in decisions: + decision_number += 1 + # TODO: check decision types mandatory attributes + # check decision type is correct + if dcs["decisionType"] not in self.KNOWN_DECISION_TYPES: + problems.append({ + "type": "bad_decision_type", + "value": dcs["decisionType"], + "where": "decisions.{}.member.decisionType".format(decision_number), + "possible_values": ", ".join(self.KNOWN_DECISION_TYPES), + }) + + # raise if any problem + if any(problems): + raise SWFDecisionValidationException(problems) + def handle_decisions(self, event_id, decisions): """ Handles a Decision according to SWF docs. diff --git a/tests/test_swf/test_decision_tasks.py b/tests/test_swf/test_decision_tasks.py index 9db1a2649..cf83ec404 100644 --- a/tests/test_swf/test_decision_tasks.py +++ b/tests/test_swf/test_decision_tasks.py @@ -6,6 +6,7 @@ from moto.swf import swf_backend from moto.swf.exceptions import ( SWFUnknownResourceFault, SWFValidationException, + SWFDecisionValidationException, ) from .utils import mock_basic_workflow_type @@ -157,3 +158,36 @@ def test_respond_decision_task_completed_with_complete_workflow_execution(): "DecisionTaskCompleted", "WorkflowExecutionCompleted", ]) + +@mock_swf +def test_respond_decision_task_completed_with_close_decision_not_last(): + conn = setup_workflow() + resp = conn.poll_for_decision_task("test-domain", "queue") + task_token = resp["taskToken"] + + decisions = [ + { "decisionType": "CompleteWorkflowExecution" }, + { "decisionType": "WeDontCare" }, + ] + + conn.respond_decision_task_completed.when.called_with( + task_token, decisions=decisions + ).should.throw(SWFValidationException, r"Close must be last decision in list") + +@mock_swf +def test_respond_decision_task_completed_with_invalid_decision_type(): + conn = setup_workflow() + resp = conn.poll_for_decision_task("test-domain", "queue") + task_token = resp["taskToken"] + + decisions = [ + { "decisionType": "BadDecisionType" }, + { "decisionType": "CompleteWorkflowExecution" }, + ] + + conn.respond_decision_task_completed.when.called_with( + task_token, decisions=decisions + ).should.throw( + SWFDecisionValidationException, + r"Value 'BadDecisionType' at 'decisions.1.member.decisionType'" + ) diff --git a/tests/test_swf/test_exceptions.py b/tests/test_swf/test_exceptions.py index a98e16feb..394493dbc 100644 --- a/tests/test_swf/test_exceptions.py +++ b/tests/test_swf/test_exceptions.py @@ -11,6 +11,7 @@ from moto.swf.exceptions import ( SWFWorkflowExecutionAlreadyStartedFault, SWFDefaultUndefinedFault, SWFValidationException, + SWFDecisionValidationException, ) from moto.swf.models import ( WorkflowType, @@ -124,3 +125,28 @@ def test_swf_validation_exception(): "__type": "com.amazon.coral.validate#ValidationException", "message": "Invalid token", }) + +def test_swf_decision_validation_error(): + ex = SWFDecisionValidationException([ + { "type": "null_value", + "where": "decisions.1.member.startTimerDecisionAttributes.startToFireTimeout" }, + { "type": "bad_decision_type", + "value": "FooBar", + "where": "decisions.1.member.decisionType", + "possible_values": "Foo, Bar, Baz"}, + ]) + + ex.status.should.equal(400) + ex.error_code.should.equal("ValidationException") + ex.body["__type"].should.equal("com.amazon.coral.validate#ValidationException") + + msg = ex.body["message"] + msg.should.match(r"^2 validation errors detected:") + msg.should.match( + r"Value null at 'decisions.1.member.startTimerDecisionAttributes.startToFireTimeout' "\ + r"failed to satisfy constraint: Member must not be null;" + ) + msg.should.match( + r"Value 'FooBar' at 'decisions.1.member.decisionType' failed to satisfy constraint: " \ + r"Member must satisfy enum value set: \[Foo, Bar, Baz\]" + )