Repository: getmoto/moto

Issue:
Add request validation to EventBridge
EventBridge is supported using @moto.mock_events, however, the requests are not validated, and so you end up not receiving errors, when sending in incorrect requests, which, as an example, omit the 'Source' argument in the put_events request


Discussion:
Thanks for raising this @lijok - will mark it as an enhancement.

Resolving patch:
diff --git a/moto/events/exceptions.py b/moto/events/exceptions.py
new file mode 100644
--- /dev/null
+++ b/moto/events/exceptions.py
@@ -0,0 +1,18 @@
+from __future__ import unicode_literals
+from moto.core.exceptions import JsonRESTError
+
+
+class ResourceNotFoundException(JsonRESTError):
+    code = 400
+
+    def __init__(self, message):
+        super(ResourceNotFoundException, self).__init__(
+            "ResourceNotFoundException", message
+        )
+
+
+class ValidationException(JsonRESTError):
+    code = 400
+
+    def __init__(self, message):
+        super(ValidationException, self).__init__("ValidationException", message)
diff --git a/moto/events/models.py b/moto/events/models.py
--- a/moto/events/models.py
+++ b/moto/events/models.py
@@ -5,6 +5,7 @@
 
 from moto.core.exceptions import JsonRESTError
 from moto.core import ACCOUNT_ID, BaseBackend, CloudFormationModel
+from moto.events.exceptions import ValidationException, ResourceNotFoundException
 from moto.utilities.tagging_service import TaggingService
 
 from uuid import uuid4
@@ -359,10 +360,52 @@ def put_events(self, events):
         if num_events < 1:
             raise JsonRESTError("ValidationError", "Need at least 1 event")
         elif num_events > 10:
-            raise JsonRESTError("ValidationError", "Can only submit 10 events at once")
+            # the exact error text is longer, the Value list consists of all the put events
+            raise ValidationException(
+                "1 validation error detected: "
+                "Value '[PutEventsRequestEntry]' at 'entries' failed to satisfy constraint: "
+                "Member must have length less than or equal to 10"
+            )
+
+        entries = []
+        for event in events:
+            if "Source" not in event:
+                entries.append(
+                    {
+                        "ErrorCode": "InvalidArgument",
+                        "ErrorMessage": "Parameter Source is not valid. Reason: Source is a required argument.",
+                    }
+                )
+            elif "DetailType" not in event:
+                entries.append(
+                    {
+                        "ErrorCode": "InvalidArgument",
+                        "ErrorMessage": "Parameter DetailType is not valid. Reason: DetailType is a required argument.",
+                    }
+                )
+            elif "Detail" not in event:
+                entries.append(
+                    {
+                        "ErrorCode": "InvalidArgument",
+                        "ErrorMessage": "Parameter Detail is not valid. Reason: Detail is a required argument.",
+                    }
+                )
+            else:
+                try:
+                    json.loads(event["Detail"])
+                except ValueError:  # json.JSONDecodeError exists since Python 3.5
+                    entries.append(
+                        {
+                            "ErrorCode": "MalformedDetail",
+                            "ErrorMessage": "Detail is malformed.",
+                        }
+                    )
+                    continue
+
+                entries.append({"EventId": str(uuid4())})
 
         # We dont really need to store the events yet
-        return [{"EventId": str(uuid4())} for _ in events]
+        return entries
 
     def remove_targets(self, name, ids):
         rule = self.rules.get(name)
@@ -479,8 +522,8 @@ def list_tags_for_resource(self, arn):
         name = arn.split("/")[-1]
         if name in self.rules:
             return self.tagger.list_tags_for_resource(self.rules[name].arn)
-        raise JsonRESTError(
-            "ResourceNotFoundException", "An entity that you specified does not exist."
+        raise ResourceNotFoundException(
+            "Rule {0} does not exist on EventBus default.".format(name)
         )
 
     def tag_resource(self, arn, tags):
@@ -488,8 +531,8 @@ def tag_resource(self, arn, tags):
         if name in self.rules:
             self.tagger.tag_resource(self.rules[name].arn, tags)
             return {}
-        raise JsonRESTError(
-            "ResourceNotFoundException", "An entity that you specified does not exist."
+        raise ResourceNotFoundException(
+            "Rule {0} does not exist on EventBus default.".format(name)
         )
 
     def untag_resource(self, arn, tag_names):
@@ -497,8 +540,8 @@ def untag_resource(self, arn, tag_names):
         if name in self.rules:
             self.tagger.untag_resource_using_names(self.rules[name].arn, tag_names)
             return {}
-        raise JsonRESTError(
-            "ResourceNotFoundException", "An entity that you specified does not exist."
+        raise ResourceNotFoundException(
+            "Rule {0} does not exist on EventBus default.".format(name)
         )
 
 


Test patch:
diff --git a/tests/test_events/test_events.py b/tests/test_events/test_events.py
--- a/tests/test_events/test_events.py
+++ b/tests/test_events/test_events.py
@@ -9,9 +9,7 @@
 import pytest
 
 from moto.core import ACCOUNT_ID
-from moto.core.exceptions import JsonRESTError
 from moto.events import mock_events
-from moto.events.models import EventsBackend
 
 RULES = [
     {"Name": "test1", "ScheduleExpression": "rate(5 minutes)"},
@@ -333,8 +331,136 @@ def test_put_events():
     response["FailedEntryCount"].should.equal(0)
     response["Entries"].should.have.length_of(1)
 
-    with pytest.raises(ClientError):
-        client.put_events(Entries=[event] * 20)
+
+@mock_events
+def test_put_events_error_too_many_entries():
+    # given
+    client = boto3.client("events", "eu-central-1")
+
+    # when
+    with pytest.raises(ClientError) as e:
+        client.put_events(
+            Entries=[
+                {
+                    "Source": "source",
+                    "DetailType": "type",
+                    "Detail": '{ "key1": "value1" }',
+                },
+            ]
+            * 11
+        )
+
+    # then
+    ex = e.value
+    ex.operation_name.should.equal("PutEvents")
+    ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
+    ex.response["Error"]["Code"].should.contain("ValidationException")
+    ex.response["Error"]["Message"].should.equal(
+        "1 validation error detected: "
+        "Value '[PutEventsRequestEntry]' at 'entries' failed to satisfy constraint: "
+        "Member must have length less than or equal to 10"
+    )
+
+
+@mock_events
+def test_put_events_error_missing_argument_source():
+    # given
+    client = boto3.client("events", "eu-central-1")
+
+    # when
+    response = client.put_events(Entries=[{}])
+
+    # then
+    response["FailedEntryCount"].should.equal(1)
+    response["Entries"].should.have.length_of(1)
+    response["Entries"][0].should.equal(
+        {
+            "ErrorCode": "InvalidArgument",
+            "ErrorMessage": "Parameter Source is not valid. Reason: Source is a required argument.",
+        }
+    )
+
+
+@mock_events
+def test_put_events_error_missing_argument_detail_type():
+    # given
+    client = boto3.client("events", "eu-central-1")
+
+    # when
+    response = client.put_events(Entries=[{"Source": "source"}])
+
+    # then
+    response["FailedEntryCount"].should.equal(1)
+    response["Entries"].should.have.length_of(1)
+    response["Entries"][0].should.equal(
+        {
+            "ErrorCode": "InvalidArgument",
+            "ErrorMessage": "Parameter DetailType is not valid. Reason: DetailType is a required argument.",
+        }
+    )
+
+
+@mock_events
+def test_put_events_error_missing_argument_detail():
+    # given
+    client = boto3.client("events", "eu-central-1")
+
+    # when
+    response = client.put_events(Entries=[{"DetailType": "type", "Source": "source"}])
+
+    # then
+    response["FailedEntryCount"].should.equal(1)
+    response["Entries"].should.have.length_of(1)
+    response["Entries"][0].should.equal(
+        {
+            "ErrorCode": "InvalidArgument",
+            "ErrorMessage": "Parameter Detail is not valid. Reason: Detail is a required argument.",
+        }
+    )
+
+
+@mock_events
+def test_put_events_error_invalid_json_detail():
+    # given
+    client = boto3.client("events", "eu-central-1")
+
+    # when
+    response = client.put_events(
+        Entries=[{"Detail": "detail", "DetailType": "type", "Source": "source"}]
+    )
+
+    # then
+    response["FailedEntryCount"].should.equal(1)
+    response["Entries"].should.have.length_of(1)
+    response["Entries"][0].should.equal(
+        {"ErrorCode": "MalformedDetail", "ErrorMessage": "Detail is malformed."}
+    )
+
+
+@mock_events
+def test_put_events_with_mixed_entries():
+    # given
+    client = boto3.client("events", "eu-central-1")
+
+    # when
+    response = client.put_events(
+        Entries=[
+            {"Source": "source"},
+            {"Detail": '{"key": "value"}', "DetailType": "type", "Source": "source"},
+            {"Detail": "detail", "DetailType": "type", "Source": "source"},
+            {"Detail": '{"key2": "value2"}', "DetailType": "type", "Source": "source"},
+        ]
+    )
+
+    # then
+    response["FailedEntryCount"].should.equal(2)
+    response["Entries"].should.have.length_of(4)
+    [
+        entry for entry in response["Entries"] if "EventId" in entry
+    ].should.have.length_of(2)
+    [
+        entry for entry in response["Entries"] if "ErrorCode" in entry
+    ].should.have.length_of(2)
 
 
 @mock_events
@@ -554,23 +680,71 @@ def test_rule_tagging_happy():
 
 
 @mock_events
-def test_rule_tagging_sad():
-    back_end = EventsBackend("us-west-2")
+def test_tag_resource_error_unknown_arn():
+    # given
+    client = boto3.client("events", "eu-central-1")
 
-    try:
-        back_end.tag_resource("unknown", [])
-        raise "tag_resource should fail if ResourceARN is not known"
-    except JsonRESTError:
-        pass
+    # when
+    with pytest.raises(ClientError) as e:
+        client.tag_resource(
+            ResourceARN="arn:aws:events:eu-central-1:{0}:rule/unknown".format(
+                ACCOUNT_ID
+            ),
+            Tags=[],
+        )
 
-    try:
-        back_end.untag_resource("unknown", [])
-        raise "untag_resource should fail if ResourceARN is not known"
-    except JsonRESTError:
-        pass
+    # then
+    ex = e.value
+    ex.operation_name.should.equal("TagResource")
+    ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
+    ex.response["Error"]["Code"].should.contain("ResourceNotFoundException")
+    ex.response["Error"]["Message"].should.equal(
+        "Rule unknown does not exist on EventBus default."
+    )
 
-    try:
-        back_end.list_tags_for_resource("unknown")
-        raise "list_tags_for_resource should fail if ResourceARN is not known"
-    except JsonRESTError:
-        pass
+
+@mock_events
+def test_untag_resource_error_unknown_arn():
+    # given
+    client = boto3.client("events", "eu-central-1")
+
+    # when
+    with pytest.raises(ClientError) as e:
+        client.untag_resource(
+            ResourceARN="arn:aws:events:eu-central-1:{0}:rule/unknown".format(
+                ACCOUNT_ID
+            ),
+            TagKeys=[],
+        )
+
+    # then
+    ex = e.value
+    ex.operation_name.should.equal("UntagResource")
+    ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
+    ex.response["Error"]["Code"].should.contain("ResourceNotFoundException")
+    ex.response["Error"]["Message"].should.equal(
+        "Rule unknown does not exist on EventBus default."
+    )
+
+
+@mock_events
+def test_list_tags_for_resource_error_unknown_arn():
+    # given
+    client = boto3.client("events", "eu-central-1")
+
+    # when
+    with pytest.raises(ClientError) as e:
+        client.list_tags_for_resource(
+            ResourceARN="arn:aws:events:eu-central-1:{0}:rule/unknown".format(
+                ACCOUNT_ID
+            )
+        )
+
+    # then
+    ex = e.value
+    ex.operation_name.should.equal("ListTagsForResource")
+    ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
+    ex.response["Error"]["Code"].should.contain("ResourceNotFoundException")
+    ex.response["Error"]["Message"].should.equal(
+        "Rule unknown does not exist on EventBus default."
+    )