Repository: getmoto/moto (python)

Issue:
AssertionError with SES Templates containing {{#if}} inside {{#each}}
Hello team,

This issue is identical to https://github.com/getmoto/moto/issues/6206 however this is happening when you have an {{#if}}...{{/if}} inside an {{#each}}.

Example Template:

`{{#each items}} {{some_variable}} {{#if some_other_var}} print me {{/if}} {{/each}}`

Starts here https://github.com/getmoto/moto/blob/master/moto/ses/template.py#L42 and this is triggering it https://github.com/getmoto/moto/blob/master/moto/ses/template.py#L26

thanks!

Pull request description:
SES: Support templates with if/else constructs
Fixes #6862 

Considering that all constructs (`each`, `if, `else`, ..) can be nested into obvivion, having a single parser was never going to be a good solution. I rewritten it so that the logic for every construct now is now in it's own separate class.

The existing integration test has been extended and is now verified against AWS.

This PR also contains the logic for `delete_template`, just because I needed to actually delete the template after the AWS test finishes.

Patch:
diff --git a/moto/ses/models.py b/moto/ses/models.py
index 1303a7a6c..0427b4105 100644
--- a/moto/ses/models.py
+++ b/moto/ses/models.py
@@ -518,6 +518,9 @@ class SESBackend(BaseBackend):
         )
         return rendered_template
 
+    def delete_template(self, name: str) -> None:
+        self.templates.pop(name)
+
     def create_receipt_rule_set(self, rule_set_name: str) -> None:
         if self.receipt_rule_set.get(rule_set_name) is not None:
             raise RuleSetNameAlreadyExists("Duplicate Receipt Rule Set Name.")
diff --git a/moto/ses/responses.py b/moto/ses/responses.py
index 186feddb8..ceef3ae19 100644
--- a/moto/ses/responses.py
+++ b/moto/ses/responses.py
@@ -281,6 +281,11 @@ class EmailResponse(BaseResponse):
         template = self.response_template(RENDER_TEMPLATE)
         return template.render(template=rendered_template)
 
+    def delete_template(self) -> str:
+        name = self._get_param("TemplateName")
+        self.backend.delete_template(name)
+        return self.response_template(DELETE_TEMPLATE).render()
+
     def create_receipt_rule_set(self) -> str:
         rule_set_name = self._get_param("RuleSetName")
         self.backend.create_receipt_rule_set(rule_set_name)
@@ -627,6 +632,14 @@ RENDER_TEMPLATE = """
 </TestRenderTemplateResponse>
 """
 
+DELETE_TEMPLATE = """<DeleteTemplateResponse xmlns="http://ses.amazonaws.com/doc/2010-12-01/">
+    <DeleteTemplateResult>
+    </DeleteTemplateResult>
+    <ResponseMetadata>
+        <RequestId>47e0ef1a-9bf2-11e1-9279-0100e8cf12ba</RequestId>
+    </ResponseMetadata>
+</DeleteTemplateResponse>"""
+
 CREATE_RECEIPT_RULE_SET = """<CreateReceiptRuleSetResponse xmlns="http://ses.amazonaws.com/doc/2010-12-01/">
   <CreateReceiptRuleSetResult/>
   <ResponseMetadata>
diff --git a/moto/ses/template.py b/moto/ses/template.py
index a153cf0db..987dc9fb9 100644
--- a/moto/ses/template.py
+++ b/moto/ses/template.py
@@ -1,53 +1,180 @@
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Optional, Type
 
 from .exceptions import MissingRenderingAttributeException
 from moto.utilities.tokenizer import GenericTokenizer
 
 
+class BlockProcessor:
+    def __init__(
+        self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+    ):
+        self.template = template
+        self.template_data = template_data
+        self.tokenizer = tokenizer
+
+    def parse(self) -> str:
+        # Added to make MyPy happy
+        # Not all implementations have this method
+        # It's up to the caller to know whether to call this method
+        raise NotImplementedError
+
+
+class EachBlockProcessor(BlockProcessor):
+    def __init__(
+        self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+    ):
+        self.template = template
+        self.tokenizer = tokenizer
+
+        self.tokenizer.skip_characters("#each")
+        self.tokenizer.skip_white_space()
+        var_name = self.tokenizer.read_until("}}").strip()
+        self.tokenizer.skip_characters("}}")
+        self.template_data = template_data.get(var_name, [])
+
+    def parse(self) -> str:
+        parsed = ""
+        current_pos = self.tokenizer.token_pos
+
+        for template_data in self.template_data:
+            self.tokenizer.token_pos = current_pos
+            for char in self.tokenizer:
+                if char == "{" and self.tokenizer.peek() == "{":
+                    self.tokenizer.skip_characters("{")
+                    self.tokenizer.skip_white_space()
+
+                    _processor = get_processor(self.tokenizer)(
+                        self.template, template_data, self.tokenizer  # type: ignore
+                    )
+                    # If we've reached the end, we should stop processing
+                    # Our parent will continue with whatever comes after {{/each}}
+                    if type(_processor) == EachEndBlockProcessor:
+                        break
+                    # If we've encountered another processor, they can continue
+                    parsed += _processor.parse()
+
+                    continue
+
+                parsed += char
+
+        return parsed
+
+
+class EachEndBlockProcessor(BlockProcessor):
+    def __init__(
+        self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+    ):
+        super().__init__(template, template_data, tokenizer)
+
+        self.tokenizer.skip_characters("/each")
+        self.tokenizer.skip_white_space()
+        self.tokenizer.skip_characters("}}")
+
+
+class IfBlockProcessor(BlockProcessor):
+    def __init__(
+        self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+    ):
+        super().__init__(template, template_data, tokenizer)
+
+        self.tokenizer.skip_characters("#if")
+        self.tokenizer.skip_white_space()
+        condition = self.tokenizer.read_until("}}").strip()
+        self.tokenizer.skip_characters("}}")
+        self.parse_contents = template_data.get(condition)
+
+    def parse(self) -> str:
+        parsed = ""
+
+        for char in self.tokenizer:
+            if char == "{" and self.tokenizer.peek() == "{":
+                self.tokenizer.skip_characters("{")
+                self.tokenizer.skip_white_space()
+
+                _processor = get_processor(self.tokenizer)(
+                    self.template, self.template_data, self.tokenizer
+                )
+                if type(_processor) == IfEndBlockProcessor:
+                    break
+                elif type(_processor) == ElseBlockProcessor:
+                    self.parse_contents = not self.parse_contents
+                    continue
+                if self.parse_contents:
+                    parsed += _processor.parse()
+
+                continue
+
+            if self.parse_contents:
+                parsed += char
+
+        return parsed
+
+
+class IfEndBlockProcessor(BlockProcessor):
+    def __init__(
+        self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+    ):
+        super().__init__(template, template_data, tokenizer)
+
+        self.tokenizer.skip_characters("/if")
+        self.tokenizer.skip_white_space()
+        self.tokenizer.skip_characters("}}")
+
+
+class ElseBlockProcessor(BlockProcessor):
+    def __init__(
+        self, template: str, template_data: Dict[str, Any], tokenizer: GenericTokenizer
+    ):
+        super().__init__(template, template_data, tokenizer)
+
+        self.tokenizer.skip_characters("else")
+        self.tokenizer.skip_white_space()
+        self.tokenizer.skip_characters("}}")
+
+
+class VarBlockProcessor(BlockProcessor):
+    def parse(self) -> str:
+        var_name = self.tokenizer.read_until("}}").strip()
+        if self.template_data.get(var_name) is None:
+            raise MissingRenderingAttributeException(var_name)
+        data: str = self.template_data.get(var_name)  # type: ignore
+        self.tokenizer.skip_white_space()
+        self.tokenizer.skip_characters("}}")
+        return data
+
+
+def get_processor(tokenizer: GenericTokenizer) -> Type[BlockProcessor]:
+    if tokenizer.peek(5) == "#each":
+        return EachBlockProcessor
+    if tokenizer.peek(5) == "/each":
+        return EachEndBlockProcessor
+    if tokenizer.peek(3) == "#if":
+        return IfBlockProcessor
+    if tokenizer.peek(3) == "/if":
+        return IfEndBlockProcessor
+    if tokenizer.peek(4) == "else":
+        return ElseBlockProcessor
+    return VarBlockProcessor
+
+
 def parse_template(
     template: str,
     template_data: Dict[str, Any],
     tokenizer: Optional[GenericTokenizer] = None,
-    until: Optional[str] = None,
 ) -> str:
     tokenizer = tokenizer or GenericTokenizer(template)
-    state = ""
+
     parsed = ""
-    var_name = ""
     for char in tokenizer:
-        if until is not None and (char + tokenizer.peek(len(until) - 1)) == until:
-            return parsed
         if char == "{" and tokenizer.peek() == "{":
             # Two braces next to each other indicate a variable/language construct such as for-each
+            # We have different processors handling different constructs
             tokenizer.skip_characters("{")
             tokenizer.skip_white_space()
-            if tokenizer.peek() == "#":
-                state = "LOOP"
-                tokenizer.skip_characters("#each")
-                tokenizer.skip_white_space()
-            else:
-                state = "VAR"
-            continue
-        if char == "}":
-            if state in ["LOOP", "VAR"]:
-                tokenizer.skip_characters("}")
-                if state == "VAR":
-                    if template_data.get(var_name) is None:
-                        raise MissingRenderingAttributeException(var_name)
-                    parsed += template_data.get(var_name)  # type: ignore
-                else:
-                    current_position = tokenizer.token_pos
-                    for item in template_data.get(var_name, []):
-                        tokenizer.token_pos = current_position
-                        parsed += parse_template(
-                            template, item, tokenizer, until="{{/each}}"
-                        )
-                        tokenizer.skip_characters("{/each}}")
-                var_name = ""
-                state = ""
-                continue
-        if state in ["LOOP", "VAR"]:
-            var_name += char
+
+            _processor = get_processor(tokenizer)(template, template_data, tokenizer)
+            parsed += _processor.parse()
             continue
+
         parsed += char
     return parsed
diff --git a/moto/utilities/tokenizer.py b/moto/utilities/tokenizer.py
index 7872f8a7c..38433d84b 100644
--- a/moto/utilities/tokenizer.py
+++ b/moto/utilities/tokenizer.py
@@ -38,6 +38,14 @@ class GenericTokenizer:
                 return ""
             raise StopIteration
 
+    def read_until(self, phrase: str) -> str:
+        chars_read = ""
+        for char in self:
+            chars_read += char
+            if self.peek(len(phrase)) == phrase:
+                return chars_read
+        return chars_read
+
     def skip_characters(self, phrase: str, case_sensitive: bool = False) -> None:
         """
         Skip the characters in the supplied phrase.


Test patch:
diff --git a/.github/workflows/tests_real_aws.yml b/.github/workflows/tests_real_aws.yml
index 6ef5bf734..342769732 100644
--- a/.github/workflows/tests_real_aws.yml
+++ b/.github/workflows/tests_real_aws.yml
@@ -42,4 +42,4 @@ jobs:
       env:
         MOTO_TEST_ALLOW_AWS_REQUEST: ${{ true }}
       run: |
-        pytest -sv tests/test_ec2/ tests/test_s3 -m aws_verified
+        pytest -sv tests/test_ec2/ tests/test_ses/ tests/test_s3 -m aws_verified
diff --git a/tests/test_ses/__init__.py b/tests/test_ses/__init__.py
index 08a1c1568..914f7dfe5 100644
--- a/tests/test_ses/__init__.py
+++ b/tests/test_ses/__init__.py
@@ -1,1 +1,28 @@
-# This file is intentionally left blank.
+import os
+from functools import wraps
+from moto import mock_ses
+
+
+def ses_aws_verified(func):
+    """
+    Function that is verified to work against AWS.
+    Can be run against AWS at any time by setting:
+      MOTO_TEST_ALLOW_AWS_REQUEST=true
+
+    If this environment variable is not set, the function runs in a `mock_ses` context.
+    """
+
+    @wraps(func)
+    def pagination_wrapper():
+        allow_aws_request = (
+            os.environ.get("MOTO_TEST_ALLOW_AWS_REQUEST", "false").lower() == "true"
+        )
+
+        if allow_aws_request:
+            resp = func()
+        else:
+            with mock_ses():
+                resp = func()
+        return resp
+
+    return pagination_wrapper
diff --git a/tests/test_ses/test_ses_boto3.py b/tests/test_ses/test_ses_boto3.py
index 7e04985c9..eda9cb1b0 100644
--- a/tests/test_ses/test_ses_boto3.py
+++ b/tests/test_ses/test_ses_boto3.py
@@ -7,6 +7,7 @@ from botocore.exceptions import ClientError
 from botocore.exceptions import ParamValidationError
 import pytest
 from moto import mock_ses
+from . import ses_aws_verified
 
 
 @mock_ses
@@ -1296,8 +1297,8 @@ def test_render_template():
     )
 
 
-@mock_ses
-def test_render_template__with_foreach():
+@ses_aws_verified
+def test_render_template__advanced():
     conn = boto3.client("ses", region_name="us-east-1")
 
     kwargs = {
@@ -1305,24 +1306,27 @@ def test_render_template__with_foreach():
         "TemplateData": json.dumps(
             {
                 "items": [
-                    {"type": "dog", "name": "bobby"},
-                    {"type": "cat", "name": "pedro"},
+                    {"type": "dog", "name": "bobby", "best": True},
+                    {"type": "cat", "name": "pedro", "best": False},
                 ]
             }
         ),
     }
 
-    conn.create_template(
-        Template={
-            "TemplateName": "MTT",
-            "SubjectPart": "..",
-            "TextPart": "..",
-            "HtmlPart": "{{#each items}} {{name}} is a {{type}}, {{/each}}",
-        }
-    )
-    result = conn.test_render_template(**kwargs)
-    assert "bobby is a dog" in result["RenderedTemplate"]
-    assert "pedro is a cat" in result["RenderedTemplate"]
+    try:
+        conn.create_template(
+            Template={
+                "TemplateName": "MTT",
+                "SubjectPart": "..",
+                "TextPart": "..",
+                "HtmlPart": "{{#each items}} {{name}} is {{#if best}}the best{{else}}a {{type}}{{/if}}, {{/each}}",
+            }
+        )
+        result = conn.test_render_template(**kwargs)
+        assert "bobby is the best" in result["RenderedTemplate"]
+        assert "pedro is a cat" in result["RenderedTemplate"]
+    finally:
+        conn.delete_template(TemplateName="MTT")
 
 
 @mock_ses
diff --git a/tests/test_ses/test_templating.py b/tests/test_ses/test_templating.py
index 9d2ba4372..723d77649 100644
--- a/tests/test_ses/test_templating.py
+++ b/tests/test_ses/test_templating.py
@@ -34,3 +34,50 @@ def test_template_with_single_curly_brace():
     assert parse_template(template, template_data={"name": "John"}) == (
         "Dear John my {brace} is fine."
     )
+
+
+def test_template_with_if():
+    template = "Dear {{ #if name }}John{{ /if }}"
+    assert parse_template(template, template_data={"name": True}) == "Dear John"
+    assert parse_template(template, template_data={"name": False}) == "Dear "
+
+
+def test_template_with_if_else():
+    template = "Dear {{ #if name }}John{{ else }}English{{ /if }}"
+    assert parse_template(template, template_data={"name": True}) == "Dear John"
+    assert parse_template(template, template_data={"name": False}) == "Dear English"
+
+
+def test_template_with_nested_foreaches():
+    template = (
+        "{{#each l}} - {{obj}} {{#each l2}} {{o1}} {{/each}} infix-each {{/each}}"
+    )
+    kwargs = {
+        "name": True,
+        "l": [
+            {
+                "obj": "it1",
+                "l2": [{"o1": "ob1", "o2": "ob2"}, {"o1": "oc1", "o2": "oc2"}],
+            },
+            {"obj": "it2", "l2": [{"o1": "ob3"}]},
+        ],
+    }
+    assert (
+        parse_template(template, kwargs)
+        == " - it1  ob1  oc1  infix-each  - it2  ob3  infix-each "
+    )
+
+
+def test_template_with_nested_ifs_and_foreaches():
+    template = "{{#each l}} - {{obj}} {{#if name}} post-if {{#each l2}} {{o1}} {{/each}} pre-if-end {{else}}elsedata{{/if}} post-if {{/each}}"
+    kwargs = {
+        "name": True,
+        "l": [
+            {"obj": "it1", "name": True, "l2": [{"o1": "ob1"}]},
+            {"obj": "it2", "name": False},
+        ],
+    }
+    assert (
+        parse_template(template, kwargs)
+        == " - it1  post-if  ob1  pre-if-end  post-if  - it2 elsedata post-if "
+    )