Repository: wagtail/wagtail

Issue:
Template fragment caching potentially harmful
### Issue Summary

Django provides optional [template fragment caching](https://docs.djangoproject.com/en/2.1/topics/cache/#template-fragment-caching) wherein a portion of a template is cached when it is first rendered and that cache is reused to render that portion of the page until the cache expires or is invalidated.

Wagtail provides two ways of viewing pages that have unpublished content: "Preview" and "View Draft."

Used in conjunction, these features can create scenarios in which live data may be cached and then rendered in preview OR in which draft data may be cached and rendered on the live site. The former is annoying, the latter is a potential vulnerability that could inadvertently leak unpublished information 😱 

Since this behavior exists _because_ all the parts are behaving as designed, it's not 100% clear to me what the solution should be. I can imagine a few possible solutions off the top of my head:

1. Add a note to the documentation somewhere cautioning against the use of template fragment caching for this reason
2. Somehow add Wagtail-specific behavior to the Django `{% cache %}` tag that causes it to no-op in draft and preview modes (may be possible through middleware or the cache API? not sure)
3. Create a Wagtail-specific template fragment caching system that respects draft and preview modes


### Steps to Reproduce

Start with a Wagtail project with the following files:

`models.py`

```python
from django.db import models

from wagtail.core.models import Page
from wagtail.admin.edit_handlers import FieldPanel


class HomePage(Page):
    a_charfield = models.CharField(max_length=255)
    
    content_panels = Page.content_panels + [
        FieldPanel('a_charfield')
    ]
```

`templates/home/home_page.html`

```html+django
{% load cache %}

{% cache 3600 homepage_charfield %}
  {{ self.a_charfield }}
{% endcache %}
```

(Generate and run all migrations, create necessary admin user, etc.)

From the Wagtail admin, edit and publish a new Home Page:

![screen shot 2019-02-12 at 09 52 22](https://user-images.githubusercontent.com/296631/52644139-45188c00-2eac-11e9-8d14-3c9e8e6255f4.png)

DO NOT visit this page. Edit the page again with new values:

![screen shot 2019-02-12 at 09 56 09](https://user-images.githubusercontent.com/296631/52644374-b48e7b80-2eac-11e9-8550-511154ae0a33.png)

Do not save, but click "Preview." You'll see a previewed page that says "Privileged Information." Now, load up the live page. Observe that it _also_ renders "Privileged Information" even though that is unsaved content that an editor was merely testing.

![screen shot 2019-02-12 at 09 57 51](https://user-images.githubusercontent.com/296631/52644821-907f6a00-2ead-11e9-8111-dd088b320d32.png)

* I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: Yes


### Technical details

Tested on:

* Python version: Python 3.7.2
* Django version: Django 2.1.7
* Wagtail version: Wagtail 2.4



Discussion:
Nice catch, @harrislapiroff. This is a tricky one.

My first thought was that Wagtail could provide an alternate `{% cache %}` tag that checks [`request.is_preview`](https://github.com/wagtail/wagtail/blob/dbb7ec77b30a7bd55201e069603b1ae3e6aa3b90/wagtail/core/models.py#L712) and skips the caching behavior if the page is being previewed. I prototyped what seemed like a reasonable solution [here](https://gist.github.com/chosak/c957a2bacb7dbc2e725423ef07a46606), and it works for the test case you provide... but might not be ideal due to something interesting/surprising about how previews work under the hood:

Wagtail uses a [`dummy_request`](https://github.com/wagtail/wagtail/blob/dbb7ec77b30a7bd55201e069603b1ae3e6aa3b90/wagtail/core/models.py#L1195) method used to generate the dummy request used for previews. As part of this method, it [applies the full middleware chain](https://github.com/wagtail/wagtail/blob/dbb7ec77b30a7bd55201e069603b1ae3e6aa3b90/wagtail/core/models.py#L1269) on the request, presumably to make the dummy request work as closely as possible to a real one. The `handler._middleware_chain` method that gets called there invokes [`django.core.handlers.base.BaseHandler._get_response`](https://github.com/django/django/blob/543fc97407a932613d283c1e0bb47616cf8782e3/django/core/handlers/base.py#L85), which not only applies all middleware `process_response` methods but also [renders the response](https://github.com/django/django/blob/543fc97407a932613d283c1e0bb47616cf8782e3/django/core/handlers/base.py#L143).

This means that Wagtail previews actually render the page **twice** -- once (with live data) as part of the middleware chain used to generate the dummy response, and then again (with draft data) to render the preview.

(Getting even more complicated, the URL used for the request is based on the [best guess](https://github.com/wagtail/wagtail/blob/dbb7ec77b30a7bd55201e069603b1ae3e6aa3b90/wagtail/core/models.py#L1206) for where the page is going to live (or even [the root URL](https://github.com/wagtail/wagtail/blob/dbb7ec77b30a7bd55201e069603b1ae3e6aa3b90/wagtail/core/models.py#L1223), I think if the page has never been published).)

This means that the live cache data for a page gets set not necessarily when the page is viewed live for the first time, but also potentially when the page is previewed. This isn't a problem as long as cache keys are tied to specific pages (because caching the live data is what you'd want to do), but it is unexpected that it'd happen on page preview.
This issue also effects any calls to the cache using custom tags, model methods and middleware. So adding a new ``{% cache %}`` wouldn't completely solve this problem.

Also, I don't think that many developers would test using the preview, so they probably wouldn't think that they need to be using a separate ``{% cache %}`` tag for Wagtail pages.

I think this could be solved by mocking out the cache backends while previews are being rendered. This should fix the issue for the other cases and also work with the Django's existing ``{% cache %}`` tag.

```python
# wagtail/admin/views/pages.py

from wagtail.utils.cache import disable_caches

def view_draft(request, page_id):
    ...

    with disable_caches():
        response = page.serve_preview(page.dummy_request(request), page.default_preview_mode)
        # Force the response to render while caches are disabled
        response.render()
        return response
```

An implementation of ``disable_caches`` might look like: https://gist.github.com/kaedroho/cefd778ff5ea85166ded1ef817c62c6d (not tested though)
Any progress?
Just leaving a note here to say that having attempted it, I don't think the approach suggested by @kaedroho is viable. 

The reason is that [Python caches module imports](https://docs.python.org/3/reference/import.html#the-module-cache), so monkey-patching `django.core.cache.caches` in this way will only affect code that attempts to import this module _after_ we modify it. Any code that has already imported the module will use the previously referenced module (clearing the cache will not help with this).

The result of this basically is that you can't monkey-patch the cache inside a request context like this.

I tested this with the following code:

```python
class DummyCacheHandler:

    def __getitem__(self, alias):
        return DummyCache(None, {})


@contextmanager
def disable_caches():
    original_caches = django.core.cache.caches
    django.core.cache.caches = DummyCacheHandler()

    try:
        yield
    finally:
        django.core.cache.caches = original_caches


class Page(BasePage):
    """
    Page model that overrides the preview() method to ensure that caches are
    bypassed when previewing.
    """

    def make_preview_request(self, *args, **kwargs):
        with disable_caches():
            response = super().make_preview_request(*args,**kwargs)
        return response

    class Meta:
        abstract = True 
```

which doesn't work as far as template fragment caching goes. By the time `make_preview_request`, `django.templatetags.cache` has already been loaded, and the `caches` import used there references the module before we changed it.

The inverse problem also applies, that any imports that happen while we have mocked out the cache will also be cached, such that the calling code will never be able to cache things.
For anyone who wants to implement a workaround until a solution is found in core, I've done this:

```python
import django
from django.templatetags.cache import CacheNode


class SmartCacheNode(CacheNode):
    """
    A subclass of django.templatetags.cache.CacheNode for Django's default cache tag
    that bypasses the cache entirely if there is a request in the context and
    request.is_preview is True.
    """
    def render(self, context):
        request = context.get('request', None)
        # If request.is_preview, then skip caching entirely
        if request and getattr(request, 'is_preview', False):
            return self.nodelist.render(context)

        return super().render(context)


# Monkey-patch Django's cache node with out own.
django.templatetags.cache.CacheNode = SmartCacheNode
```

This (a) doesn't deal with caching outside of templates and (b) will not work for template fragments that don't have `request` in the context - so it only partially mitigates the security risk posed by this bug.
Similarly to @solarissmoke 's solution, this is the modified version of the jinja2 cache tag from [django-jinja](https://github.com/niwinz/django-jinja) .

```python
from django.core.cache import cache
from django.utils.encoding import force_text
from django_jinja.builtins.extensions import make_template_fragment_key
from jinja2 import TemplateSyntaxError
from jinja2 import nodes
from jinja2.ext import Extension


class CacheExtension(Extension):
    tags = {'cache'}

    def parse(self, parser):
        lineno = next(parser.stream).lineno

        expire_time = parser.parse_expression()
        fragment_name = parser.parse_expression()
        vary_on = []

        while not parser.stream.current.test('block_end'):
            vary_on.append(parser.parse_expression())

        body = parser.parse_statements(['name:endcache'], drop_needle=True)

        return nodes.CallBlock(
            self.call_method('_cache_support',
                             [expire_time, fragment_name,
                              nodes.List(vary_on), nodes.Const(lineno),
                              nodes.ContextReference()]),
            [], [], body).set_lineno(lineno)

    def _cache_support(self, expire_time, fragm_name, vary_on, lineno, context, caller):
        try:
            expire_time = int(expire_time)
        except (ValueError, TypeError):
            raise TemplateSyntaxError('"%s" tag got a non-integer timeout '
                'value: %r' % (list(self.tags)[0], expire_time), lineno)

        cache_key = make_template_fragment_key(fragm_name, vary_on)

        request = context.get('request', None)
        # If request.is_preview, then skip caching entirely
        if request and getattr(request, 'is_preview', False):
            return caller()

        value = cache.get(cache_key)
        if value is None:
            value = caller()
            cache.set(cache_key, force_text(value), expire_time)
        else:
            value = force_text(value)

        return value

```

Resolving patch:
diff --git a/wagtail/coreutils.py b/wagtail/coreutils.py
--- a/wagtail/coreutils.py
+++ b/wagtail/coreutils.py
@@ -10,6 +10,7 @@
 from django.apps import apps
 from django.conf import settings
 from django.conf.locale import LANG_INFO
+from django.core.cache.utils import make_template_fragment_key
 from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
 from django.core.signals import setting_changed
 from django.db.models import Model
@@ -560,3 +561,14 @@ def _do_processing(self):
     def get_summary(self):
         opts = self.model._meta
         return f"{self.created_count}/{self.added_count} {opts.verbose_name_plural} were created successfully."
+
+
+def make_wagtail_template_fragment_key(fragment_name, page, site, vary_on=None):
+    """
+    A modified version of `make_template_fragment_key` which varies on page and
+    site for use with `{% wagtailpagecache %}`.
+    """
+    if vary_on is None:
+        vary_on = []
+    vary_on.extend([page.cache_key, site.id])
+    return make_template_fragment_key(fragment_name, vary_on)
diff --git a/wagtail/models/__init__.py b/wagtail/models/__init__.py
--- a/wagtail/models/__init__.py
+++ b/wagtail/models/__init__.py
@@ -42,7 +42,7 @@
 from django.utils import timezone
 from django.utils import translation as translation
 from django.utils.cache import patch_cache_control
-from django.utils.encoding import force_str
+from django.utils.encoding import force_bytes, force_str
 from django.utils.functional import Promise, cached_property
 from django.utils.module_loading import import_string
 from django.utils.text import capfirst, slugify
@@ -70,6 +70,7 @@
     get_content_type_label,
     get_supported_content_language_variant,
     resolve_model_string,
+    safe_md5,
 )
 from wagtail.fields import StreamField
 from wagtail.forms import TaskStateCommentForm
@@ -2429,6 +2430,34 @@ def get_cached_paths(self):
         """
         return ["/"]
 
+    def get_cache_key_components(self):
+        """
+        The components of a :class:`Page` which make up the :attr:`cache_key`. Any change to a
+        page should be reflected in a change to at least one of these components.
+        """
+
+        return [
+            self.id,
+            self.url_path,
+            self.last_published_at.isoformat() if self.last_published_at else None,
+        ]
+
+    @property
+    def cache_key(self):
+        """
+        A generic cache key to identify a page in its current state.
+        Should the page change, so will the key.
+
+        Customizations to the cache key should be made in :attr:`get_cache_key_components`.
+        """
+
+        hasher = safe_md5()
+
+        for component in self.get_cache_key_components():
+            hasher.update(force_bytes(component))
+
+        return hasher.hexdigest()
+
     def get_sitemap_urls(self, request=None):
         return [
             {
diff --git a/wagtail/templatetags/wagtail_cache.py b/wagtail/templatetags/wagtail_cache.py
new file mode 100644
--- /dev/null
+++ b/wagtail/templatetags/wagtail_cache.py
@@ -0,0 +1,98 @@
+from django import template
+from django.template import Variable
+from django.template.exceptions import TemplateSyntaxError
+from django.templatetags.cache import CacheNode as DjangoCacheNode
+
+from wagtail.models import PAGE_TEMPLATE_VAR, Site
+
+register = template.Library()
+
+
+class WagtailCacheNode(DjangoCacheNode):
+    """
+    A modified version of Django's `CacheNode` which is aware of Wagtail's
+    page previews.
+    """
+
+    def render(self, context):
+        try:
+            request = context["request"]
+        except KeyError:
+            # When there's no request, it's not possible to tell whether this is a preview or not.
+            # Bypass the cache to be safe.
+            return self.nodelist.render(context)
+
+        if getattr(request, "is_preview", False):
+            # Skip cache in preview
+            return self.nodelist.render(context)
+
+        return super().render(context)
+
+
+class WagtailPageCacheNode(WagtailCacheNode):
+    """
+    A modified version of Django's `CacheNode` designed for caching fragments
+    of pages.
+
+    This tag intentionally makes assumptions about what context is available.
+    If these assumptions aren't valid, it's recommended to just use `{% wagtailcache %}`.
+    """
+
+    CACHE_SITE_TEMPLATE_VAR = "wagtail_page_cache_site"
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Pretend the user specified the page and site as part of context
+        self.vary_on.extend(
+            [
+                Variable(f"{PAGE_TEMPLATE_VAR}.cache_key"),
+                Variable(f"{self.CACHE_SITE_TEMPLATE_VAR}.pk"),
+            ]
+        )
+
+    def render(self, context):
+        if "request" in context:
+            # Inject the site into context to be picked up when resolving `vary_on`
+            with context.update(
+                {
+                    self.CACHE_SITE_TEMPLATE_VAR: Site.find_for_request(
+                        context["request"]
+                    )
+                }
+            ):
+                return super().render(context)
+        return super().render(context)
+
+
+def register_cache_tag(tag_name, node_class):
+    """
+    A helper function to define cache tags without duplicating `do_cache`.
+    """
+
+    @register.tag(tag_name)
+    def do_cache(parser, token):
+        # Implementation copied from `django.templatetags.cache.do_cache`
+        nodelist = parser.parse((f"end{tag_name}",))
+        parser.delete_first_token()
+        tokens = token.split_contents()
+        if len(tokens) < 3:
+            raise TemplateSyntaxError(
+                f"'{tokens[0]}' tag requires at least 2 arguments."
+            )
+        if len(tokens) > 3 and tokens[-1].startswith("using="):
+            cache_name = parser.compile_filter(tokens[-1][len("using=") :])
+            tokens = tokens[:-1]
+        else:
+            cache_name = None
+        return node_class(
+            nodelist,
+            parser.compile_filter(tokens[1]),
+            tokens[2],  # fragment_name can't be a variable.
+            [parser.compile_filter(t) for t in tokens[3:]],
+            cache_name,
+        )
+
+
+register_cache_tag("wagtailcache", WagtailCacheNode)
+register_cache_tag("wagtailpagecache", WagtailPageCacheNode)


Test patch:
diff --git a/wagtail/tests/test_page_model.py b/wagtail/tests/test_page_model.py
--- a/wagtail/tests/test_page_model.py
+++ b/wagtail/tests/test_page_model.py
@@ -3795,3 +3795,26 @@ def test_when_scheduled_for_publish(self):
         # This is because it shouldn't be possible to create a separate draft from what is scheduled to be published
         superuser = get_user_model().objects.get(email="superuser@example.com")
         self.assertTrue(lock.for_user(superuser))
+
+
+class TestPageCacheKey(TestCase):
+    fixtures = ["test.json"]
+
+    def setUp(self):
+        self.page = Page.objects.last()
+        self.other_page = Page.objects.first()
+
+    def test_cache_key_consistent(self):
+        self.assertEqual(self.page.cache_key, self.page.cache_key)
+        self.assertEqual(self.other_page.cache_key, self.other_page.cache_key)
+
+    def test_no_queries(self):
+        with self.assertNumQueries(0):
+            self.page.cache_key
+            self.other_page.cache_key
+
+    def test_changes_when_slug_changes(self):
+        original_cache_key = self.page.cache_key
+        self.page.slug = "something-else"
+        self.page.save()
+        self.assertNotEqual(self.page.cache_key, original_cache_key)
diff --git a/wagtail/tests/tests.py b/wagtail/tests/tests.py
--- a/wagtail/tests/tests.py
+++ b/wagtail/tests/tests.py
@@ -2,18 +2,25 @@
 
 from django import template
 from django.core.cache import cache
+from django.core.cache.utils import make_template_fragment_key
 from django.http import HttpRequest
+from django.template import TemplateSyntaxError, VariableDoesNotExist
 from django.test import TestCase
 from django.test.utils import override_settings
 from django.urls.exceptions import NoReverseMatch
 from django.utils.safestring import SafeString
 
-from wagtail.coreutils import get_dummy_request, resolve_model_string
+from wagtail.coreutils import (
+    get_dummy_request,
+    make_wagtail_template_fragment_key,
+    resolve_model_string,
+)
 from wagtail.models import Locale, Page, Site, SiteRootPath
 from wagtail.models.sites import (
     SITE_ROOT_PATHS_CACHE_KEY,
     SITE_ROOT_PATHS_CACHE_VERSION,
 )
+from wagtail.templatetags.wagtail_cache import WagtailPageCacheNode
 from wagtail.templatetags.wagtailcore_tags import richtext, slugurl
 from wagtail.test.testapp.models import SimplePage
 
@@ -543,3 +550,257 @@ def test_call_with_bytes(self):
             TypeError, "'richtext' template filter received an invalid value"
         ):
             richtext(b"Hello world!")
+
+
+class TestWagtailCacheTag(TestCase):
+    def setUp(self):
+        cache.clear()
+
+    def test_caches(self):
+        request = get_dummy_request()
+        tpl = template.Template(
+            """{% load wagtail_cache %}{% wagtailcache 100 test %}{{ foo.bar }}{% endwagtailcache %}"""
+        )
+
+        result = tpl.render(
+            template.Context({"request": request, "foo": {"bar": "foobar"}})
+        )
+        self.assertEqual(result, "foobar")
+
+        result2 = tpl.render(
+            template.Context({"request": request, "foo": {"bar": "baz"}})
+        )
+        self.assertEqual(result2, "foobar")
+
+        self.assertEqual(cache.get(make_template_fragment_key("test")), "foobar")
+
+    def test_caches_on_additional_parameters(self):
+        request = get_dummy_request()
+        tpl = template.Template(
+            """{% load wagtail_cache %}{% wagtailcache 100 test foo %}{{ foo.bar }}{% endwagtailcache %}"""
+        )
+
+        result = tpl.render(
+            template.Context({"request": request, "foo": {"bar": "foobar"}})
+        )
+        self.assertEqual(result, "foobar")
+
+        result2 = tpl.render(
+            template.Context({"request": request, "foo": {"bar": "baz"}})
+        )
+        self.assertEqual(result2, "baz")
+
+        self.assertEqual(
+            cache.get(make_template_fragment_key("test", [{"bar": "foobar"}])), "foobar"
+        )
+        self.assertEqual(
+            cache.get(make_template_fragment_key("test", [{"bar": "baz"}])), "baz"
+        )
+
+    def test_skips_cache_in_preview(self):
+        request = get_dummy_request()
+        request.is_preview = True
+
+        tpl = template.Template(
+            """{% load wagtail_cache %}{% wagtailcache 100 test %}{{ foo.bar }}{% endwagtailcache %}"""
+        )
+
+        result = tpl.render(
+            template.Context({"request": request, "foo": {"bar": "foobar"}})
+        )
+        self.assertEqual(result, "foobar")
+
+        result2 = tpl.render(
+            template.Context({"request": request, "foo": {"bar": "baz"}})
+        )
+        self.assertEqual(result2, "baz")
+
+        self.assertIsNone(cache.get(make_template_fragment_key("test")))
+
+    def test_no_request(self):
+        tpl = template.Template(
+            """{% load wagtail_cache %}{% wagtailcache 100 test %}{{ foo.bar }}{% endwagtailcache %}"""
+        )
+
+        result = tpl.render(template.Context({"foo": {"bar": "foobar"}}))
+        self.assertEqual(result, "foobar")
+
+        result2 = tpl.render(template.Context({"foo": {"bar": "baz"}}))
+        self.assertEqual(result2, "baz")
+
+        self.assertIsNone(cache.get(make_template_fragment_key("test")))  #
+
+    def test_invalid_usage(self):
+        with self.assertRaises(TemplateSyntaxError) as e:
+            template.Template(
+                """{% load wagtail_cache %}{% wagtailcache 100 %}{{ foo.bar }}{% endwagtailcache %}"""
+            )
+        self.assertEqual(
+            e.exception.args[0], "'wagtailcache' tag requires at least 2 arguments."
+        )
+
+
+class TestWagtailPageCacheTag(TestCase):
+    fixtures = ["test.json"]
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.page_1 = Page.objects.first()
+        cls.page_2 = Page.objects.all()[2]
+        cls.site = Site.objects.get(hostname="localhost", port=80)
+
+    def test_caches(self):
+        request = get_dummy_request(site=self.site)
+        tpl = template.Template(
+            """{% load wagtail_cache %}{% wagtailpagecache 100 test %}{{ foo.bar }}{% endwagtailpagecache %}"""
+        )
+
+        result = tpl.render(
+            template.Context(
+                {"request": request, "foo": {"bar": "foobar"}, "page": self.page_1}
+            )
+        )
+        self.assertEqual(result, "foobar")
+
+        result2 = tpl.render(
+            template.Context(
+                {"request": request, "foo": {"bar": "baz"}, "page": self.page_1}
+            )
+        )
+        self.assertEqual(result2, "foobar")
+
+        self.assertEqual(
+            cache.get(
+                make_wagtail_template_fragment_key("test", self.page_1, self.site)
+            ),
+            "foobar",
+        )
+
+    def test_caches_additional_parameters(self):
+        request = get_dummy_request(site=self.site)
+        tpl = template.Template(
+            """{% load wagtail_cache %}{% wagtailpagecache 100 test foo %}{{ foo.bar }}{% endwagtailpagecache %}"""
+        )
+
+        result = tpl.render(
+            template.Context(
+                {"request": request, "foo": {"bar": "foobar"}, "page": self.page_1}
+            )
+        )
+        self.assertEqual(result, "foobar")
+
+        result2 = tpl.render(
+            template.Context(
+                {"request": request, "foo": {"bar": "baz"}, "page": self.page_1}
+            )
+        )
+        self.assertEqual(result2, "baz")
+
+        self.assertEqual(
+            cache.get(
+                make_wagtail_template_fragment_key(
+                    "test", self.page_1, self.site, [{"bar": "foobar"}]
+                )
+            ),
+            "foobar",
+        )
+        self.assertEqual(
+            cache.get(
+                make_wagtail_template_fragment_key(
+                    "test", self.page_1, self.site, [{"bar": "baz"}]
+                )
+            ),
+            "baz",
+        )
+
+    def test_doesnt_pollute_cache(self):
+        request = get_dummy_request(site=self.site)
+        tpl = template.Template(
+            """{% load wagtail_cache %}{% wagtailpagecache 100 test %}{{ foo.bar }}{% endwagtailpagecache %}"""
+        )
+
+        context = template.Context(
+            {"request": request, "foo": {"bar": "foobar"}, "page": self.page_1}
+        )
+        result = tpl.render(context)
+        self.assertEqual(result, "foobar")
+
+        self.assertNotIn(WagtailPageCacheNode.CACHE_SITE_TEMPLATE_VAR, context)
+
+    def test_skips_cache_in_preview(self):
+        request = get_dummy_request(site=self.site)
+        request.is_preview = True
+
+        tpl = template.Template(
+            """{% load wagtail_cache %}{% wagtailpagecache 100 test %}{{ foo.bar }}{% endwagtailpagecache %}"""
+        )
+
+        result = tpl.render(
+            template.Context(
+                {"request": request, "foo": {"bar": "foobar"}, "page": self.page_1}
+            )
+        )
+        self.assertEqual(result, "foobar")
+
+        result2 = tpl.render(
+            template.Context(
+                {"request": request, "foo": {"bar": "baz"}, "page": self.page_1}
+            )
+        )
+        self.assertEqual(result2, "baz")
+
+        self.assertIsNone(
+            cache.get(
+                make_wagtail_template_fragment_key("test", self.page_1, self.site)
+            )
+        )
+
+    def test_no_request(self):
+        tpl = template.Template(
+            """{% load wagtail_cache %}{% wagtailpagecache 100 test %}{{ foo.bar }}{% endwagtailpagecache %}"""
+        )
+
+        result = tpl.render(
+            template.Context({"foo": {"bar": "foobar"}, "page": self.page_1})
+        )
+        self.assertEqual(result, "foobar")
+
+        result2 = tpl.render(
+            template.Context({"foo": {"bar": "baz"}, "page": self.page_1})
+        )
+        self.assertEqual(result2, "baz")
+
+        self.assertIsNone(
+            cache.get(
+                make_wagtail_template_fragment_key("test", self.page_1, self.site)
+            )
+        )
+
+    def test_no_page(self):
+        request = get_dummy_request()
+
+        tpl = template.Template(
+            """{% load wagtail_cache %}{% wagtailpagecache 100 test %}{{ foo.bar }}{% endwagtailpagecache %}"""
+        )
+
+        with self.assertRaises(VariableDoesNotExist) as e:
+            tpl.render(template.Context({"request": request, "foo": {"bar": "foobar"}}))
+
+        self.assertEqual(e.exception.params[0], "page")
+
+    def test_cache_key(self):
+        self.assertEqual(
+            make_wagtail_template_fragment_key("test", self.page_1, self.site),
+            make_template_fragment_key(
+                "test", vary_on=[self.page_1.cache_key, self.site.id]
+            ),
+        )
+
+    def test_invalid_usage(self):
+        with self.assertRaises(TemplateSyntaxError) as e:
+            template.Template(
+                """{% load wagtail_cache %}{% wagtailpagecache 100 %}{{ foo.bar }}{% endwagtailpagecache %}"""
+            )
+        self.assertEqual(
+            e.exception.args[0], "'wagtailpagecache' tag requires at least 2 arguments."
+        )