Repository: getmoto/moto
Pull request #6456 (2023-06-28 21:25:34)

APIGatewayV2: Stages
 - create_stage()
 - delete_stage()
 - get_stage()
 - get_stages()


Patch:
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index 763cb8036..d2def447b 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -204,7 +204,7 @@
 
 ## apigatewayv2
 <details>
-<summary>69% implemented</summary>
+<summary>75% implemented</summary>
 
 - [X] create_api
 - [X] create_api_mapping
@@ -216,7 +216,7 @@
 - [X] create_model
 - [X] create_route
 - [X] create_route_response
-- [ ] create_stage
+- [X] create_stage
 - [X] create_vpc_link
 - [ ] delete_access_log_settings
 - [X] delete_api
@@ -232,7 +232,7 @@
 - [X] delete_route_request_parameter
 - [X] delete_route_response
 - [ ] delete_route_settings
-- [ ] delete_stage
+- [X] delete_stage
 - [X] delete_vpc_link
 - [ ] export_api
 - [X] get_api
@@ -256,8 +256,8 @@
 - [X] get_route_response
 - [ ] get_route_responses
 - [X] get_routes
-- [ ] get_stage
-- [ ] get_stages
+- [X] get_stage
+- [X] get_stages
 - [X] get_tags
 - [X] get_vpc_link
 - [X] get_vpc_links
@@ -6784,7 +6784,7 @@
 - [ ] deregister_managed_instance
 - [ ] deregister_patch_baseline_for_patch_group
 - [X] deregister_target_from_maintenance_window
-- [ ] deregister_task_from_maintenance_window
+- [X] deregister_task_from_maintenance_window
 - [ ] describe_activations
 - [ ] describe_association
 - [ ] describe_association_execution_targets
@@ -6807,7 +6807,7 @@
 - [ ] describe_maintenance_window_executions
 - [ ] describe_maintenance_window_schedule
 - [X] describe_maintenance_window_targets
-- [ ] describe_maintenance_window_tasks
+- [X] describe_maintenance_window_tasks
 - [X] describe_maintenance_windows
 - [ ] describe_maintenance_windows_for_target
 - [ ] describe_ops_items
@@ -6868,7 +6868,7 @@
 - [ ] register_default_patch_baseline
 - [ ] register_patch_baseline_for_patch_group
 - [X] register_target_with_maintenance_window
-- [ ] register_task_with_maintenance_window
+- [X] register_task_with_maintenance_window
 - [X] remove_tags_from_resource
 - [ ] reset_service_setting
 - [ ] resume_session
diff --git a/docs/docs/services/apigatewayv2.rst b/docs/docs/services/apigatewayv2.rst
index 452254ce5..7be34f0ff 100644
--- a/docs/docs/services/apigatewayv2.rst
+++ b/docs/docs/services/apigatewayv2.rst
@@ -46,7 +46,7 @@ apigatewayv2
         The following parameters are not yet implemented: ResponseModels, ResponseParameters
         
 
-- [ ] create_stage
+- [X] create_stage
 - [X] create_vpc_link
 - [ ] delete_access_log_settings
 - [X] delete_api
@@ -62,7 +62,7 @@ apigatewayv2
 - [X] delete_route_request_parameter
 - [X] delete_route_response
 - [ ] delete_route_settings
-- [ ] delete_stage
+- [X] delete_stage
 - [X] delete_vpc_link
 - [ ] export_api
 - [X] get_api
@@ -102,8 +102,8 @@ apigatewayv2
         Pagination is not yet implemented
         
 
-- [ ] get_stage
-- [ ] get_stages
+- [X] get_stage
+- [X] get_stages
 - [X] get_tags
 - [X] get_vpc_link
 - [X] get_vpc_links
diff --git a/moto/apigatewayv2/exceptions.py b/moto/apigatewayv2/exceptions.py
index 1a63b65a9..44ef021d6 100644
--- a/moto/apigatewayv2/exceptions.py
+++ b/moto/apigatewayv2/exceptions.py
@@ -123,3 +123,13 @@ class ApiMappingNotFound(APIGatewayV2Error):
             "NotFoundException",
             "The api mapping resource specified in the request was not found.",
         )
+
+
+class StageNotFound(APIGatewayV2Error):
+    code = 404
+
+    def __init__(self) -> None:
+        super().__init__(
+            "NotFoundException",
+            "Invalid stage identifier specified",
+        )
diff --git a/moto/apigatewayv2/models.py b/moto/apigatewayv2/models.py
index 7842e5a2b..5ec2b3992 100644
--- a/moto/apigatewayv2/models.py
+++ b/moto/apigatewayv2/models.py
@@ -22,9 +22,57 @@ from .exceptions import (
     VpcLinkNotFound,
     DomainNameNotFound,
     DomainNameAlreadyExists,
+    StageNotFound,
 )
 
 
+class Stage(BaseModel):
+    def __init__(self, api: "Api", config: Dict[str, Any]):
+        self.config = config
+        self.name = config["stageName"]
+        if api.protocol_type == "HTTP":
+            self.default_route_settings = config.get(
+                "defaultRouteSettings", {"detailedMetricsEnabled": False}
+            )
+        elif api.protocol_type == "WEBSOCKET":
+            self.default_route_settings = config.get(
+                "defaultRouteSettings",
+                {
+                    "dataTraceEnabled": False,
+                    "detailedMetricsEnabled": False,
+                    "loggingLevel": "OFF",
+                },
+            )
+        self.access_log_settings = config.get("accessLogSettings")
+        self.auto_deploy = config.get("autoDeploy")
+        self.client_certificate_id = config.get("clientCertificateId")
+        self.description = config.get("description")
+        self.route_settings = config.get("routeSettings", {})
+        self.stage_variables = config.get("stageVariables", {})
+        self.tags = config.get("tags", {})
+        self.created = self.updated = unix_time()
+
+    def to_json(self) -> Dict[str, Any]:
+        dct = {
+            "stageName": self.name,
+            "defaultRouteSettings": self.default_route_settings,
+            "createdDate": self.created,
+            "lastUpdatedDate": self.updated,
+            "routeSettings": self.route_settings,
+            "stageVariables": self.stage_variables,
+            "tags": self.tags,
+        }
+        if self.access_log_settings:
+            dct["accessLogSettings"] = self.access_log_settings
+        if self.auto_deploy is not None:
+            dct["autoDeploy"] = self.auto_deploy
+        if self.client_certificate_id:
+            dct["clientCertificateId"] = self.client_certificate_id
+        if self.description:
+            dct["description"] = self.description
+        return dct
+
+
 class Authorizer(BaseModel):
     def __init__(
         self,
@@ -541,6 +589,7 @@ class Api(BaseModel):
         self.integrations: Dict[str, Integration] = dict()
         self.models: Dict[str, Model] = dict()
         self.routes: Dict[str, Route] = dict()
+        self.stages: Dict[str, Stage] = dict()
 
         self.arn = f"arn:aws:apigateway:{region}::/apis/{self.api_id}"
         self.backend.tag_resource(self.arn, tags)
@@ -550,6 +599,7 @@ class Api(BaseModel):
         self.integrations.clear()
         self.models.clear()
         self.routes.clear()
+        self.stages.clear()
 
     def delete_cors_configuration(self) -> None:
         self.cors_configuration = None
@@ -962,6 +1012,19 @@ class Api(BaseModel):
         route = self.get_route(route_id)
         return route.get_route_response(route_response_id)
 
+    def create_stage(self, config: Dict[str, Any]) -> Stage:
+        stage = Stage(api=self, config=config)
+        self.stages[stage.name] = stage
+        return stage
+
+    def get_stage(self, stage_name: str) -> Stage:
+        if stage_name not in self.stages:
+            raise StageNotFound
+        return self.stages[stage_name]
+
+    def delete_stage(self, stage_name: str) -> None:
+        self.stages.pop(stage_name, None)
+
     def to_json(self) -> Dict[str, Any]:
         return {
             "apiId": self.api_id,
@@ -1599,7 +1662,6 @@ class ApiGatewayV2Backend(BaseBackend):
         mutual_tls_authentication: Dict[str, str],
         tags: Dict[str, str],
     ) -> DomainName:
-
         if domain_name in self.domain_names.keys():
             raise DomainNameAlreadyExists
 
@@ -1701,5 +1763,21 @@ class ApiGatewayV2Backend(BaseBackend):
 
         del self.api_mappings[api_mapping_id]
 
+    def create_stage(self, api_id: str, config: Dict[str, Any]) -> Stage:
+        api = self.get_api(api_id)
+        return api.create_stage(config)
+
+    def get_stage(self, api_id: str, stage_name: str) -> Stage:
+        api = self.get_api(api_id)
+        return api.get_stage(stage_name)
+
+    def delete_stage(self, api_id: str, stage_name: str) -> None:
+        api = self.get_api(api_id)
+        api.delete_stage(stage_name)
+
+    def get_stages(self, api_id: str) -> List[Stage]:
+        api = self.get_api(api_id)
+        return list(api.stages.values())
+
 
 apigatewayv2_backends = BackendDict(ApiGatewayV2Backend, "apigatewayv2")
diff --git a/moto/apigatewayv2/responses.py b/moto/apigatewayv2/responses.py
index 91ac11669..82f1c143c 100644
--- a/moto/apigatewayv2/responses.py
+++ b/moto/apigatewayv2/responses.py
@@ -212,6 +212,22 @@ class ApiGatewayV2Response(BaseResponse):
         if request.method == "DELETE":
             return self.delete_api_mapping()
 
+    def stages(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
+        self.setup_class(request, full_url, headers)
+
+        if self.method == "POST":
+            return self.create_stage()
+        if self.method == "GET":
+            return self.get_stages()
+
+    def stage(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
+        self.setup_class(request, full_url, headers)
+
+        if self.method == "GET":
+            return self.get_stage()
+        if self.method == "DELETE":
+            return self.delete_stage()
+
     def create_api(self) -> TYPE_RESPONSE:
         params = json.loads(self.body)
 
@@ -854,3 +870,26 @@ class ApiGatewayV2Response(BaseResponse):
             domain_name=domain_name,
         )
         return 204, {}, ""
+
+    def create_stage(self) -> TYPE_RESPONSE:
+        api_id = self.path.split("/")[-2]
+        config = json.loads(self.body)
+        stage = self.apigatewayv2_backend.create_stage(api_id, config)
+        return 200, {}, json.dumps(stage.to_json())
+
+    def get_stage(self) -> TYPE_RESPONSE:
+        api_id = self.path.split("/")[-3]
+        stage_name = self.path.split("/")[-1]
+        stage = self.apigatewayv2_backend.get_stage(api_id, stage_name)
+        return 200, {}, json.dumps(stage.to_json())
+
+    def delete_stage(self) -> TYPE_RESPONSE:
+        api_id = self.path.split("/")[-3]
+        stage_name = self.path.split("/")[-1]
+        self.apigatewayv2_backend.delete_stage(api_id, stage_name)
+        return 200, {}, "{}"
+
+    def get_stages(self) -> TYPE_RESPONSE:
+        api_id = self.path.split("/")[-2]
+        stages = self.apigatewayv2_backend.get_stages(api_id)
+        return 200, {}, json.dumps({"items": [st.to_json() for st in stages]})
diff --git a/moto/apigatewayv2/urls.py b/moto/apigatewayv2/urls.py
index b737112e8..7826904a6 100644
--- a/moto/apigatewayv2/urls.py
+++ b/moto/apigatewayv2/urls.py
@@ -26,6 +26,8 @@ url_paths = {
     "{0}/v2/apis/(?P<api_id>[^/]+)/routes/(?P<route_id>[^/]+)/routeresponses$": response_v2.route_responses,
     "{0}/v2/apis/(?P<api_id>[^/]+)/routes/(?P<route_id>[^/]+)/routeresponses/(?P<route_response_id>[^/]+)$": response_v2.route_response,
     "{0}/v2/apis/(?P<api_id>[^/]+)/routes/(?P<route_id>[^/]+)/requestparameters/(?P<request_parameter>[^/]+)$": response_v2.route_request_parameter,
+    "{0}/v2/apis/(?P<api_id>[^/]+)/stages$": response_v2.stages,
+    "{0}/v2/apis/(?P<api_id>[^/]+)/stages/(?P<stage_name>[^/]+)$": response_v2.stage,
     "{0}/v2/tags/(?P<resource_arn>[^/]+)$": response_v2.tags,
     "{0}/v2/tags/(?P<resource_arn_pt1>[^/]+)/apis/(?P<resource_arn_pt2>[^/]+)$": response_v2.tags,
     "{0}/v2/tags/(?P<resource_arn_pt1>[^/]+)/vpclinks/(?P<resource_arn_pt2>[^/]+)$": response_v2.tags,
diff --git a/moto/ec2/models/security_groups.py b/moto/ec2/models/security_groups.py
index 1e458e751..0e3c91665 100644
--- a/moto/ec2/models/security_groups.py
+++ b/moto/ec2/models/security_groups.py
@@ -550,16 +550,19 @@ class SecurityGroupBackend:
 
     def describe_security_group_rules(
         self, group_ids: Optional[List[str]] = None, filters: Any = None
-    ) -> List[SecurityRule]:
+    ) -> Dict[str, List[SecurityRule]]:
         matches = self.describe_security_groups(group_ids=group_ids, filters=filters)
         if not matches:
             raise InvalidSecurityGroupNotFoundError(
                 "No security groups found matching the filters provided."
             )
-        rules = []
+        rules = {}
         for group in matches:
-            rules.extend(group.ingress_rules)
-            rules.extend(group.egress_rules)
+            group_rules = []
+            group_rules.extend(group.ingress_rules)
+            group_rules.extend(group.egress_rules)
+            if group_rules:
+                rules[group.group_id] = group_rules
 
         return rules
 
diff --git a/moto/ec2/responses/security_groups.py b/moto/ec2/responses/security_groups.py
index 2742f6518..04f2f8b54 100644
--- a/moto/ec2/responses/security_groups.py
+++ b/moto/ec2/responses/security_groups.py
@@ -254,22 +254,25 @@ DESCRIBE_SECURITY_GROUP_RULES_RESPONSE = """
 <DescribeSecurityGroupRulesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
   <requestId>{{ request_id }}</requestId>
   <securityGroupRuleSet>
-    {% for rule in rules %}
-    <item>
-        {% if rule.from_port is not none %}
-        <fromPort>{{ rule.from_port }}</fromPort>
-        {% endif %}
-        {% if rule.to_port is not none %}
-          <toPort>{{ rule.to_port }}</toPort>
-        {% endif %}
-        {% if rule.ip_ranges %}
-          <cidrIpv4>{{ rule.ip_ranges[0]['CidrIp'] }}</cidrIpv4>
-        {% endif %}
-        <ipProtocol>{{ rule.ip_protocol }}</ipProtocol>
-        <groupOwnerId>{{ rule.owner_id }}</groupOwnerId>
-        <isEgress>{{ 'true' if rule.is_egress else 'false' }}</isEgress>
-        <securityGroupRuleId>{{ rule.id }}</securityGroupRuleId>
-    </item>
+    {% for group, rule_list in rules.items() %}
+        {% for rule in rule_list %}
+            <item>
+                {% if rule.from_port is not none %}
+                <fromPort>{{ rule.from_port }}</fromPort>
+                {% endif %}
+                {% if rule.to_port is not none %}
+                  <toPort>{{ rule.to_port }}</toPort>
+                {% endif %}
+                {% if rule.ip_ranges %}
+                  <cidrIpv4>{{ rule.ip_ranges[0]['CidrIp'] }}</cidrIpv4>
+                {% endif %}
+                <ipProtocol>{{ rule.ip_protocol }}</ipProtocol>
+                <groupId>{{ group }}</groupId>
+                <groupOwnerId>{{ rule.owner_id }}</groupOwnerId>
+                <isEgress>{{ 'true' if rule.is_egress else 'false' }}</isEgress>
+                <securityGroupRuleId>{{ rule.id }}</securityGroupRuleId>
+            </item>
+        {% endfor %}
     {% endfor %}
   </securityGroupRuleSet>
 </DescribeSecurityGroupRulesResponse>"""
diff --git a/moto/ssm/models.py b/moto/ssm/models.py
index f3c8734b0..d88deb808 100644
--- a/moto/ssm/models.py
+++ b/moto/ssm/models.py
@@ -975,6 +975,65 @@ class FakeMaintenanceWindowTarget:
         return str(random.uuid4())
 
 
+class FakeMaintenanceWindowTask:
+    def __init__(
+        self,
+        window_id: str,
+        targets: Optional[List[Dict[str, Any]]],
+        task_arn: str,
+        service_role_arn: Optional[str],
+        task_type: str,
+        task_parameters: Optional[Dict[str, Any]],
+        task_invocation_parameters: Optional[Dict[str, Any]],
+        priority: Optional[int],
+        max_concurrency: Optional[str],
+        max_errors: Optional[str],
+        logging_info: Optional[Dict[str, Any]],
+        name: Optional[str],
+        description: Optional[str],
+        cutoff_behavior: Optional[str],
+        alarm_configurations: Optional[Dict[str, Any]],
+    ):
+        self.window_task_id = FakeMaintenanceWindowTask.generate_id()
+        self.window_id = window_id
+        self.task_type = task_type
+        self.task_arn = task_arn
+        self.service_role_arn = service_role_arn
+        self.task_parameters = task_parameters
+        self.priority = priority
+        self.max_concurrency = max_concurrency
+        self.max_errors = max_errors
+        self.logging_info = logging_info
+        self.name = name
+        self.description = description
+        self.targets = targets
+        self.task_invocation_parameters = task_invocation_parameters
+        self.cutoff_behavior = cutoff_behavior
+        self.alarm_configurations = alarm_configurations
+
+    def to_json(self) -> Dict[str, Any]:
+        return {
+            "WindowId": self.window_id,
+            "WindowTaskId": self.window_task_id,
+            "TaskType": self.task_type,
+            "TaskArn": self.task_arn,
+            "ServiceRoleArn": self.service_role_arn,
+            "TaskParameters": self.task_parameters,
+            "Priority": self.priority,
+            "MaxConcurrency": self.max_concurrency,
+            "MaxErrors": self.max_errors,
+            "LoggingInfo": self.logging_info,
+            "Name": self.name,
+            "Description": self.description,
+            "Targets": self.targets,
+            "TaskInvocationParameters": self.task_invocation_parameters,
+        }
+
+    @staticmethod
+    def generate_id() -> str:
+        return str(random.uuid4())
+
+
 def _maintenance_window_target_filter_match(
     filters: Optional[List[Dict[str, Any]]], target: FakeMaintenanceWindowTarget
 ) -> bool:
@@ -983,6 +1042,14 @@ def _maintenance_window_target_filter_match(
     return False
 
 
+def _maintenance_window_task_filter_match(
+    filters: Optional[List[Dict[str, Any]]], task: FakeMaintenanceWindowTask
+) -> bool:
+    if not filters and task:
+        return True
+    return False
+
+
 class FakeMaintenanceWindow:
     def __init__(
         self,
@@ -1007,6 +1074,7 @@ class FakeMaintenanceWindow:
         self.start_date = start_date
         self.end_date = end_date
         self.targets: Dict[str, FakeMaintenanceWindowTarget] = {}
+        self.tasks: Dict[str, FakeMaintenanceWindowTask] = {}
 
     def to_json(self) -> Dict[str, Any]:
         return {
@@ -2351,5 +2419,62 @@ class SimpleSystemManagerBackend(BaseBackend):
         ]
         return targets
 
+    def register_task_with_maintenance_window(
+        self,
+        window_id: str,
+        targets: Optional[List[Dict[str, Any]]],
+        task_arn: str,
+        service_role_arn: Optional[str],
+        task_type: str,
+        task_parameters: Optional[Dict[str, Any]],
+        task_invocation_parameters: Optional[Dict[str, Any]],
+        priority: Optional[int],
+        max_concurrency: Optional[str],
+        max_errors: Optional[str],
+        logging_info: Optional[Dict[str, Any]],
+        name: Optional[str],
+        description: Optional[str],
+        cutoff_behavior: Optional[str],
+        alarm_configurations: Optional[Dict[str, Any]],
+    ) -> str:
+
+        window = self.get_maintenance_window(window_id)
+        task = FakeMaintenanceWindowTask(
+            window_id,
+            targets,
+            task_arn,
+            service_role_arn,
+            task_type,
+            task_parameters,
+            task_invocation_parameters,
+            priority,
+            max_concurrency,
+            max_errors,
+            logging_info,
+            name,
+            description,
+            cutoff_behavior,
+            alarm_configurations,
+        )
+        window.tasks[task.window_task_id] = task
+        return task.window_task_id
+
+    def describe_maintenance_window_tasks(
+        self, window_id: str, filters: List[Dict[str, Any]]
+    ) -> List[FakeMaintenanceWindowTask]:
+        window = self.get_maintenance_window(window_id)
+        tasks = [
+            task
+            for task in window.tasks.values()
+            if _maintenance_window_task_filter_match(filters, task)
+        ]
+        return tasks
+
+    def deregister_task_from_maintenance_window(
+        self, window_id: str, window_task_id: str
+    ) -> None:
+        window = self.get_maintenance_window(window_id)
+        del window.tasks[window_task_id]
+
 
 ssm_backends = BackendDict(SimpleSystemManagerBackend, "ssm")
diff --git a/moto/ssm/responses.py b/moto/ssm/responses.py
index 3f757f03d..e488b6193 100644
--- a/moto/ssm/responses.py
+++ b/moto/ssm/responses.py
@@ -513,3 +513,42 @@ class SimpleSystemManagerResponse(BaseResponse):
         baseline_id = self._get_param("BaselineId")
         self.ssm_backend.delete_patch_baseline(baseline_id)
         return "{}"
+
+    def register_task_with_maintenance_window(self) -> str:
+        window_task_id = self.ssm_backend.register_task_with_maintenance_window(
+            window_id=self._get_param("WindowId"),
+            targets=self._get_param("Targets"),
+            task_arn=self._get_param("TaskArn"),
+            service_role_arn=self._get_param("ServiceRoleArn"),
+            task_type=self._get_param("TaskType"),
+            task_parameters=self._get_param("TaskParameters"),
+            task_invocation_parameters=self._get_param("TaskInvocationParameters"),
+            priority=self._get_param("Priority"),
+            max_concurrency=self._get_param("MaxConcurrency"),
+            max_errors=self._get_param("MaxErrors"),
+            logging_info=self._get_param("LoggingInfo"),
+            name=self._get_param("Name"),
+            description=self._get_param("Description"),
+            cutoff_behavior=self._get_param("CutoffBehavior"),
+            alarm_configurations=self._get_param("AlarmConfigurations"),
+        )
+        return json.dumps({"WindowTaskId": window_task_id})
+
+    def describe_maintenance_window_tasks(self) -> str:
+        window_id = self._get_param("WindowId")
+        filters = self._get_param("Filters", [])
+        tasks = [
+            task.to_json()
+            for task in self.ssm_backend.describe_maintenance_window_tasks(
+                window_id, filters
+            )
+        ]
+        return json.dumps({"Tasks": tasks})
+
+    def deregister_task_from_maintenance_window(self) -> str:
+        window_id = self._get_param("WindowId")
+        window_task_id = self._get_param("WindowTaskId")
+        self.ssm_backend.deregister_task_from_maintenance_window(
+            window_id, window_task_id
+        )
+        return "{}"


Test patch:
diff --git a/tests/terraformtests/terraform-tests.success.txt b/tests/terraformtests/terraform-tests.success.txt
index bd41fd494..4a5430eb5 100644
--- a/tests/terraformtests/terraform-tests.success.txt
+++ b/tests/terraformtests/terraform-tests.success.txt
@@ -30,6 +30,9 @@ apigatewayv2:
   - TestAccAPIGatewayV2IntegrationResponse
   - TestAccAPIGatewayV2Model
   - TestAccAPIGatewayV2Route
+  - TestAccAPIGatewayV2Stage_basicHTTP
+  - TestAccAPIGatewayV2Stage_basicWebSocket
+  - TestAccAPIGatewayV2Stage_disappears
   - TestAccAPIGatewayV2VPCLink
 appconfig:
   - TestAccAppConfigConfigurationProfileDataSource_basic
diff --git a/tests/test_apigatewayv2/test_apigatewayv2_stages.py b/tests/test_apigatewayv2/test_apigatewayv2_stages.py
new file mode 100644
index 000000000..3dfe47332
--- /dev/null
+++ b/tests/test_apigatewayv2/test_apigatewayv2_stages.py
@@ -0,0 +1,95 @@
+import boto3
+import pytest
+from botocore.exceptions import ClientError
+from moto import mock_apigatewayv2
+
+
+@mock_apigatewayv2
+def test_create_stage__defaults():
+    client = boto3.client("apigatewayv2", "us-east-2")
+
+    api_id = client.create_api(Name="test-api", ProtocolType="HTTP")["ApiId"]
+
+    stages = client.get_stages(ApiId=api_id)
+    assert stages["Items"] == []
+
+    stage = client.create_stage(ApiId=api_id, StageName="my_stage")
+    assert stage["StageName"] == "my_stage"
+
+    resp = client.get_stage(ApiId=api_id, StageName="my_stage")
+    assert resp["StageName"] == "my_stage"
+
+    stages = client.get_stages(ApiId=api_id)
+    assert len(stages["Items"]) == 1
+
+    assert stages["Items"][0]["StageName"] == "my_stage"
+    assert stages["Items"][0]["DefaultRouteSettings"] == {
+        "DetailedMetricsEnabled": False
+    }
+    assert stages["Items"][0]["CreatedDate"]
+    assert stages["Items"][0]["LastUpdatedDate"]
+    assert stages["Items"][0]["RouteSettings"] == {}
+    assert stages["Items"][0]["StageVariables"] == {}
+    assert stages["Items"][0]["Tags"] == {}
+
+    client.delete_stage(ApiId=api_id, StageName="my_stage")
+
+    with pytest.raises(ClientError) as exc:
+        client.get_stage(ApiId=api_id, StageName="my_stage")
+    err = exc.value.response["Error"]
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == "Invalid stage identifier specified"
+
+
+@mock_apigatewayv2
+def test_create_stage__defaults_for_websocket_api():
+    client = boto3.client("apigatewayv2", "us-east-2")
+
+    api_id = client.create_api(Name="test-api", ProtocolType="WEBSOCKET")["ApiId"]
+
+    client.create_stage(ApiId=api_id, StageName="my_stage")
+
+    stage = client.get_stage(ApiId=api_id, StageName="my_stage")
+    assert stage["StageName"] == "my_stage"
+    assert stage["DefaultRouteSettings"] == {
+        "DataTraceEnabled": False,
+        "DetailedMetricsEnabled": False,
+        "LoggingLevel": "OFF",
+    }
+
+
+@mock_apigatewayv2
+def test_create_stage():
+    client = boto3.client("apigatewayv2", "us-east-2")
+
+    api_id = client.create_api(Name="test-api", ProtocolType="HTTP")["ApiId"]
+
+    stage = client.create_stage(
+        ApiId=api_id,
+        StageName="my_stage",
+        AccessLogSettings={"Format": "JSON"},
+        AutoDeploy=True,
+        ClientCertificateId="ccid",
+        DefaultRouteSettings={"LoggingLevel": "INFO", "ThrottlingBurstLimit": 9000},
+        Description="my shiny stage",
+        RouteSettings={
+            "route1": {"DataTraceEnabled": True},
+        },
+        StageVariables={"sv1": "val1"},
+        Tags={"k1": "v1"},
+    )
+
+    assert stage["StageName"] == "my_stage"
+    assert stage["AccessLogSettings"] == {"Format": "JSON"}
+    assert stage["AutoDeploy"] is True
+    assert stage["ClientCertificateId"] == "ccid"
+    assert stage["DefaultRouteSettings"] == {
+        "LoggingLevel": "INFO",
+        "ThrottlingBurstLimit": 9000,
+    }
+    assert stage["Description"] == "my shiny stage"
+    assert stage["CreatedDate"]
+    assert stage["LastUpdatedDate"]
+    assert stage["RouteSettings"] == {"route1": {"DataTraceEnabled": True}}
+    assert stage["StageVariables"] == {"sv1": "val1"}
+    assert stage["Tags"] == {"k1": "v1"}
diff --git a/tests/test_ec2/test_security_groups.py b/tests/test_ec2/test_security_groups.py
index c7cc6dc87..70d859d8e 100644
--- a/tests/test_ec2/test_security_groups.py
+++ b/tests/test_ec2/test_security_groups.py
@@ -586,6 +586,7 @@ def test_create_and_describe_security_grp_rule():
     assert rules[0]["IsEgress"] is True
     assert rules[0]["IpProtocol"] == "-1"
     assert rules[0]["CidrIpv4"] == "0.0.0.0/0"
+    assert "GroupId" in rules[0]
 
 
 @mock_ec2
diff --git a/tests/test_ssm/test_ssm_maintenance_windows.py b/tests/test_ssm/test_ssm_maintenance_windows.py
index f484a4252..9d0e2ca26 100644
--- a/tests/test_ssm/test_ssm_maintenance_windows.py
+++ b/tests/test_ssm/test_ssm_maintenance_windows.py
@@ -274,3 +274,111 @@ def test_deregister_target_from_maintenance_window():
         WindowId=window_id,
     )
     resp.should.have.key("Targets").should.have.length_of(0)
+
+
+@mock_ssm
+def test_describe_maintenance_window_with_no_task_or_targets():
+    ssm = boto3.client("ssm", region_name="us-east-1")
+
+    resp = ssm.create_maintenance_window(
+        Name="simple-window",
+        Schedule="cron(15 12 * * ? *)",
+        Duration=2,
+        Cutoff=1,
+        AllowUnassociatedTargets=False,
+    )
+    window_id = resp["WindowId"]
+
+    resp = ssm.describe_maintenance_window_tasks(
+        WindowId=window_id,
+    )
+    resp.should.have.key("Tasks").should.have.length_of(0)
+
+    resp = ssm.describe_maintenance_window_targets(
+        WindowId=window_id,
+    )
+    resp.should.have.key("Targets").should.have.length_of(0)
+
+
+@mock_ssm
+def test_register_maintenance_window_task():
+    ssm = boto3.client("ssm", region_name="us-east-1")
+
+    resp = ssm.create_maintenance_window(
+        Name="simple-window",
+        Schedule="cron(15 12 * * ? *)",
+        Duration=2,
+        Cutoff=1,
+        AllowUnassociatedTargets=False,
+    )
+    window_id = resp["WindowId"]
+
+    resp = ssm.register_target_with_maintenance_window(
+        WindowId=window_id,
+        ResourceType="INSTANCE",
+        Targets=[{"Key": "tag:Name", "Values": ["my-instance"]}],
+    )
+    window_target_id = resp["WindowTargetId"]
+
+    resp = ssm.register_task_with_maintenance_window(
+        WindowId=window_id,
+        Targets=[{"Key": "WindowTargetIds", "Values": [window_target_id]}],
+        TaskArn="AWS-RunShellScript",
+        TaskType="RUN_COMMAND",
+        MaxConcurrency="1",
+        MaxErrors="1",
+    )
+
+    resp.should.have.key("WindowTaskId")
+    _id = resp["WindowTaskId"]
+
+    resp = ssm.describe_maintenance_window_tasks(
+        WindowId=window_id,
+    )
+    resp.should.have.key("Tasks").should.have.length_of(1)
+    resp["Tasks"][0].should.have.key("WindowTaskId").equal(_id)
+    resp["Tasks"][0].should.have.key("WindowId").equal(window_id)
+    resp["Tasks"][0].should.have.key("TaskArn").equal("AWS-RunShellScript")
+    resp["Tasks"][0].should.have.key("MaxConcurrency").equal("1")
+    resp["Tasks"][0].should.have.key("MaxErrors").equal("1")
+
+
+@mock_ssm
+def test_deregister_maintenance_window_task():
+    ssm = boto3.client("ssm", region_name="us-east-1")
+
+    resp = ssm.create_maintenance_window(
+        Name="simple-window",
+        Schedule="cron(15 12 * * ? *)",
+        Duration=2,
+        Cutoff=1,
+        AllowUnassociatedTargets=False,
+    )
+    window_id = resp["WindowId"]
+
+    resp = ssm.register_target_with_maintenance_window(
+        WindowId=window_id,
+        ResourceType="INSTANCE",
+        Targets=[{"Key": "tag:Name", "Values": ["my-instance"]}],
+    )
+    window_target_id = resp["WindowTargetId"]
+
+    resp = ssm.register_task_with_maintenance_window(
+        WindowId=window_id,
+        Targets=[{"Key": "WindowTargetIds", "Values": [window_target_id]}],
+        TaskArn="AWS-RunShellScript",
+        TaskType="RUN_COMMAND",
+        MaxConcurrency="1",
+        MaxErrors="1",
+    )
+    window_task_id = resp["WindowTaskId"]
+
+    ssm.deregister_task_from_maintenance_window(
+        WindowId=window_id,
+        WindowTaskId=window_task_id,
+    )
+
+    resp = ssm.describe_maintenance_window_tasks(
+        WindowId=window_id,
+    )
+    resp.should.have.key("Tasks").should.have.length_of(0)