Repository: famedly/synapse-invite-checker
Issue category: Feature Request

Issue:
GET /v1/server/isInsurance
```
  /v1/server/isInsurance:
    get:
      tags:
        - lookUpServer
      summary: "Check whether a TI-Messenger server name represents an insurance."
      parameters:
        - in: query
          name: serverName
          description: "The server name to query."
          required: true
          schema:
            type: string
      responses:
        "200":
          description: "OK"
          content:
            application/json:
              schema:
                type: object
                required:
                  - isInsurance
                properties:
                  isInsurance:
                    type: boolean
                    description: "`true` if the server name represents an insurance or `false` otherwise."
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
```

This endpoint will return the information if a server is an insurance server or not. This information is stored on the federationlist. Currently there is no test environment for the federation list and also the schema provided by gematik ([link](https://github.com/gematik/api-vzd/blob/main/src/schema/FederationList.json)) is not updated. To be able to implement this, we need to use the openapi spec, there they have the new definition ([link](https://github.com/gematik/api-vzd/blob/05a042ca67d49d231042576234aee940a12a6a52/src/openapi/I_VZD_TIM_Provider_Services.yaml#L510-L548)).

This endpoint should only be enabled in tim-pro mode.


Hints:

Patch:
diff --git a/README.md b/README.md
index 17b9c64..30f9ffd 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ modules:
         federation_localization_url: "https://localhost:8080/localization", # Full url where to fetch the federation localization from, required. Should be the same host as federation list.
         federation_list_client_cert: "tests/certs/client.pem", # path to a pem encoded client certificate for mtls, required if federation list url is https
         gematik_ca_baseurl: "https://download-ref.tsl.ti-dienste.de/", # the baseurl to the ca to use for the federation list, required
+        tim-type: "epa" or "pro", # Patient/Insurance or Professional mode, defaults to "pro" mode. Optional currently, but will be required in a later release
 ```
 
 ## Testing
diff --git a/synapse_invite_checker/config.py b/synapse_invite_checker/config.py
index 970ec22..bb3dc27 100644
--- a/synapse_invite_checker/config.py
+++ b/synapse_invite_checker/config.py
@@ -14,6 +14,8 @@
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
 from dataclasses import dataclass
 
+from synapse_invite_checker.types import TimType
+
 
 @dataclass
 class InviteCheckerConfig:
@@ -24,3 +26,4 @@ class InviteCheckerConfig:
     federation_list_client_cert: str = ""
     federation_localization_url: str = ""
     gematik_ca_baseurl: str = ""
+    tim_type: TimType = TimType.PRO
diff --git a/synapse_invite_checker/invite_checker.py b/synapse_invite_checker/invite_checker.py
index 5762758..055228d 100644
--- a/synapse_invite_checker/invite_checker.py
+++ b/synapse_invite_checker/invite_checker.py
@@ -17,7 +17,7 @@
 import logging
 import time
 from contextlib import suppress
-from typing import Literal
+from typing import Any, Callable, Dict, Literal, cast
 from urllib.parse import quote, urlparse
 
 from cachetools import TTLCache, keys
@@ -32,6 +32,7 @@
     load_certificate,
 )
 from synapse.api.constants import AccountDataTypes
+from synapse.config import ConfigError
 from synapse.http.client import BaseHttpClient
 from synapse.http.proxyagent import ProxyAgent
 from synapse.http.server import JsonResource
@@ -39,6 +40,7 @@
 from synapse.server import HomeServer
 from synapse.storage.database import make_conn
 from synapse.types import UserID
+from twisted.internet.defer import Deferred
 from twisted.internet.ssl import PrivateCertificate, optionsForClientTLS, platformTrust
 from twisted.web.client import HTTPConnectionPool
 from twisted.web.iweb import IPolicyForHTTPS
@@ -50,10 +52,14 @@
     ContactResource,
     ContactsResource,
 )
-from synapse_invite_checker.rest.messenger_info import MessengerInfoResource
+from synapse_invite_checker.rest.messenger_info import (
+    MessengerInfoResource,
+    MessengerIsInsuranceResource,
+)
 
 from synapse_invite_checker.store import InviteCheckerStore
-from synapse_invite_checker.types import FederationList
+from synapse_invite_checker.types import FederationList, TimType
+
 
 logger = logging.getLogger(__name__)
 
@@ -179,14 +185,20 @@ def __init__(self, config: InviteCheckerConfig, api: ModuleApi):
 
         # The TiMessengerInformation API resource
         MessengerInfoResource(self.config).register(self.resource)
+        MessengerIsInsuranceResource(self.config, self.is_domain_insurance).register(
+            self.resource
+        )
 
         # Register everything at the root of our namespace/app, to avoid complicating
         # Synapse's regex http registration
         self.api.register_web_resource(BASE_API_PREFIX, self.resource)
+
+        self.api._hs._reactor.callWhenRunning(self.after_startup)
+
         logger.info("Module initialized at %s", BASE_API_PREFIX)
 
     @staticmethod
-    def parse_config(config):
+    def parse_config(config: Dict[str, Any]) -> InviteCheckerConfig:
         logger.error("PARSE CONFIG")
         _config = InviteCheckerConfig()
 
@@ -223,8 +235,41 @@ def parse_config(config):
             msg = "Federation list config requires an mtls (PEM) cert for https connections"
             raise Exception(msg)
 
+        # Check that the configuration is defined. This allows a grace period for
+        # migration. For now, just issue a warning in the logs. The default of 'pro'
+        # is set inside InviteCheckerConfig
+        _tim_type = config.get("tim-type", "").lower()
+        if not _tim_type:
+            logger.warning(
+                "Please remember to set `tim-type` in your configuration. Defaulting to 'Pro' mode"
+            )
+
+        else:
+            if _tim_type == "epa":
+                _config.tim_type = TimType.EPA
+            elif _tim_type == "pro":
+                _config.tim_type = TimType.PRO
+            else:
+                msg = "`tim-type` setting is not a recognized value. Please fix."
+                raise ConfigError(msg)
+
         return _config
 
+    def after_startup(self) -> None:
+        """
+        To be called when the rector is running. Validates that the epa setting matches
+        the insurance setting in the federation list.
+        """
+        fetch_deferred = Deferred.fromCoroutine(self._fetch_federation_list())
+        fed_list = cast(FederationList, fetch_deferred.result)
+        if self.config.tim_type == TimType.EPA and not fed_list.is_insurance(
+            self.api._hs.config.server.server_name
+        ):
+            logger.warning(
+                "This server has enabled ePA Mode in its config, but is not found on "
+                "the Federation List as an Insurance Domain!"
+            )
+
     async def _raw_localization_fetch(self, mxid: str) -> str:
         resp = await self.federation_list_client.get_raw(
             self.config.federation_localization_url, {"mxid": mxid}
@@ -296,13 +341,15 @@ def _load_cert_b64(self, cert: str) -> X509:
         return load_certificate(FILETYPE_ASN1, base64.b64decode(cert))
 
     @cached(cache=TTLCache(maxsize=1, ttl=60 * 60))
-    async def fetch_federation_allow_list(self) -> tuple[set[str], FederationList]:
+    async def _fetch_federation_list(
+        self,
+    ) -> FederationList:
         """
         Fetch the raw data for the federation list, verify it is authentic and parse
         the data into a usable format
 
-        Returns: a tuple of a set of the domains from the list and the complete
-            validated list(for testing)
+        Returns:
+            a FederationList object
 
         """
         raw_list = await self._raw_federation_list_fetch()
@@ -336,15 +383,38 @@ async def fetch_federation_allow_list(self) -> tuple[set[str], FederationList]:
             raise Exception(msg)
 
         # Validate incoming, potentially incomplete or corrupt data
-        fed_list = FederationList.model_validate_json(jws_verify.payload)
-        return {fed.domain for fed in fed_list.domainList}, fed_list
+        return FederationList.model_validate_json(jws_verify.payload)
+
+    async def _domain_list_check(self, check: Callable[[str], bool]) -> bool:
+        """Run a `check` against data found on the FederationList"""
+        fed_list = await self._fetch_federation_list()
+        if check(fed_list):
+            return True
+
+        # Per A_25537:
+        # The domain wasn't found but the list may have changed since the last look.
+        # Re-fetch the list and try again
+        self._fetch_federation_list.cache_clear()
+        fed_list = await self._fetch_federation_list()
+        return check(fed_list)
+
+    async def is_domain_allowed(self, domain: str) -> bool:
+        return await self._domain_list_check(lambda fl: fl.allowed(domain))
+
+    async def is_domain_insurance(self, domain: str) -> bool:
+        return await self._domain_list_check(lambda fl: fl.is_insurance(domain))
 
     async def user_may_invite(
         self, inviter: str, invitee: str, room_id: str
     ) -> Literal["NOT_SPAM"] | errors.Codes:
         # Check local invites first, no need to check federation invites for those
-        # This verifies that local users can't invite into their DMs as verified by a few tests in the Testsuite
         if self.api.is_mine(inviter):
+            if self.config.tim_type == TimType.EPA:
+                # The TIM-ePA backend forbids all local invites
+                if self.api.is_mine(invitee):
+                    return errors.Codes.FORBIDDEN
+
+            # Verify that local users can't invite into their DMs as verified by a few tests in the Testsuite
             direct = await self.api.account_data_manager.get_global(
                 inviter, AccountDataTypes.DIRECT
             )
@@ -362,23 +432,34 @@ async def user_may_invite(
             # local invites are always valid, if they are not to a dm.
             # There are no checks done for outgoing invites apart from in the federation proxy,
             # which checks all requests.
-            # if self.api.is_mine(invitee):
             logger.debug("Local invite from %s to %s allowed", inviter, invitee)
             return NOT_SPAM
 
-        # Step 1, check federation allow list
         inviter_id = UserID.from_string(inviter)
         inviter_domain = inviter_id.domain
-        domain_set, _ = await self.fetch_federation_allow_list()
-        if inviter_domain not in domain_set:
-            self.fetch_federation_allow_list.cache_clear()
-            domain_set, _ = await self.fetch_federation_allow_list()
 
-            if inviter_domain not in domain_set:
-                logger.warning("Discarding invite from domain: %s", inviter_domain)
-                return errors.Codes.FORBIDDEN
+        # Step 1a, check federation allow list
+        if not await self.is_domain_allowed(inviter_domain):
+            logger.warning("Discarding invite from domain: %s", inviter_domain)
+            return errors.Codes.FORBIDDEN
+
+        # Step 1b
+        # Per AF_10233: Deny incoming remote invites if in ePA mode(which means the
+        # local user is an 'insured') and if the remote domain is type 'insurance'.
+
+        # TODO: stop trusting our local setting and instead check the federation list
+        #  data to see if *this* server is of type `isInsurance`
+        if self.config.tim_type == TimType.EPA and await self.is_domain_insurance(
+            inviter_domain
+        ):
+            logger.warning(
+                "Discarding invite from remote insurance domain: %s", inviter_domain
+            )
+            return errors.Codes.FORBIDDEN
 
         # Step 2, check invite settings
+        # TODO: This section checks for "Block all but allow exceptions" behavior. Need
+        #  to develop the inverse as well, "Allow all but block exceptions"
         contact = await self.store.get_contact(UserID.from_string(invitee), inviter)
         seconds_since_epoch = int(time.time())
         if (
@@ -398,7 +479,7 @@ async def user_may_invite(
 
         # Step 3, no active invite settings found, ensure we
         # - either invite an org
-        # - or both users are practitioners and the invitee has no restrcited visibility
+        # - or both users are practitioners and the invitee has no restricted visibility
         # The values org, pract, orgPract stand for org membership, practitioner and both respectively.
         invitee_loc = await self.fetch_localization_for_mxid(invitee)
         if invitee_loc in {"orgPract", "org"}:
@@ -415,7 +496,7 @@ async def user_may_invite(
         inviter_loc = await self.fetch_localization_for_mxid(inviter)
         if invitee_loc in visiblePract and inviter_loc in visiblePract:
             logger.debug(
-                "Allowing invite since invitee (%s) and inviter (%s) are both practioners (%s and %s)",
+                "Allowing invite since invitee (%s) and inviter (%s) are both practitioners (%s and %s)",
                 invitee,
                 inviter,
                 invitee_loc,
@@ -424,7 +505,7 @@ async def user_may_invite(
             return NOT_SPAM
 
         logger.debug(
-            "Not allowing invite since invitee (%s) and inviter (%s) are not both practioners (%s and %s) and all previous checks failed",
+            "Not allowing invite since invitee (%s) and inviter (%s) are not both practitioners (%s and %s) and all previous checks failed",
             invitee,
             inviter,
             invitee_loc,
diff --git a/synapse_invite_checker/rest/messenger_info.py b/synapse_invite_checker/rest/messenger_info.py
index c770a49..ffe1700 100644
--- a/synapse_invite_checker/rest/messenger_info.py
+++ b/synapse_invite_checker/rest/messenger_info.py
@@ -14,14 +14,16 @@
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
 import re
 from http import HTTPStatus
-from typing import List
+from typing import Awaitable, Callable, List
 
-from synapse.http.servlet import RestServlet
+from synapse.http.servlet import RestServlet, parse_string
 from synapse.http.site import SynapseRequest
+from synapse.module_api import errors
 from synapse.types import JsonDict
 
 from synapse_invite_checker.config import InviteCheckerConfig
 from synapse_invite_checker.rest.base import invite_checker_pattern
+from synapse_invite_checker.types import TimType
 
 # Version of TiMessengerInformation interface. See:
 # https://github.com/gematik/api-ti-messenger/blob/main/src/openapi/TiMessengerInformation.yaml
@@ -42,7 +44,6 @@ def __init__(self, config: InviteCheckerConfig):
 
         self.PATTERNS = tim_info_patterns("/$")
 
-    # @override
     async def on_GET(self, _: SynapseRequest) -> tuple[int, JsonDict]:
         return HTTPStatus.OK, {
             "title": self.config.title,
@@ -50,3 +51,29 @@ async def on_GET(self, _: SynapseRequest) -> tuple[int, JsonDict]:
             "contact": self.config.contact,
             "version": self.version,
         }
+
+
+class MessengerIsInsuranceResource(RestServlet):
+    def __init__(
+        self,
+        config: InviteCheckerConfig,
+        is_insurance_cb: Callable[[str], Awaitable[bool]],
+    ):
+        super().__init__()
+        self.config = config
+        self.is_insurance_cb = is_insurance_cb
+
+        self.PATTERNS = tim_info_patterns("/v1/server/isInsurance$")
+
+    async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
+        if self.config.tim_type == TimType.EPA:
+            return 401, {
+                "errorCode": errors.Codes.UNAUTHORIZED,
+                "errorMessage": "Unauthorized to access for ePA backend",
+            }
+
+        server_name = parse_string(request, "serverName", required=True)
+        isInsurance = await self.is_insurance_cb(server_name)
+        return HTTPStatus.OK, {
+            "isInsurance": isInsurance,
+        }
diff --git a/synapse_invite_checker/types.py b/synapse_invite_checker/types.py
index 5c30d63..3300c88 100644
--- a/synapse_invite_checker/types.py
+++ b/synapse_invite_checker/types.py
@@ -12,9 +12,10 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
+from enum import Enum, auto
+from functools import cached_property
 
-
-from pydantic import BaseModel, ConfigDict
+from pydantic import BaseModel, ConfigDict, computed_field
 
 
 class InviteSettings(BaseModel):
@@ -61,3 +62,40 @@ class FederationList(BaseModel):
     )
 
     domainList: list[FederationDomain]  # noqa: N815
+
+    @computed_field
+    @cached_property
+    def _domains_on_list(self) -> set[str]:
+        """
+        The deduplicated domains found on the Federation List
+        """
+        return {domain_data.domain for domain_data in self.domainList}
+
+    @computed_field
+    @cached_property
+    def _insurance_domains_on_list(self) -> set[str]:
+        """
+        Only the domains that are also type 'isInsurance'
+        """
+        return {
+            domain_data.domain
+            for domain_data in self.domainList
+            if domain_data.isInsurance
+        }
+
+    def allowed(self, domain: str) -> bool:
+        """
+        Compare against the domains from the Federation List to determine if they are allowed
+        """
+        return domain in self._domains_on_list
+
+    def is_insurance(self, domain: str) -> bool:
+        """
+        Is this domain specifically designated as 'isInsurance'
+        """
+        return domain in self._insurance_domains_on_list
+
+
+class TimType(Enum):
+    PRO = auto()
+    EPA = auto()


Test patch:
diff --git a/tests/base.py b/tests/base.py
index c7a7e0d..2bae754 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -236,6 +236,7 @@ def default_config(self) -> dict[str, Any]:
                 {
                     "module": "synapse_invite_checker.InviteChecker",
                     "config": {
+                        "tim-type": "pro",
                         "federation_list_url": "http://dummy.test/FederationList/federationList.jws",
                         "federation_localization_url": "http://dummy.test/localization",
                         "federation_list_client_cert": "tests/certs/client.pem",
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..98a2dda
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,55 @@
+# Copyright (C) 2020, 2024 Famedly
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+from unittest import TestCase
+
+from synapse.config import ConfigError
+
+from synapse_invite_checker import InviteChecker
+from synapse_invite_checker.types import TimType
+
+
+class ConfigParsingTestCase(TestCase):
+    """
+    Test that parsing the config can generate Exceptions.
+
+    We start with a basic configuration, and copy it for each test. That is then
+    modified to generate the Exceptions.
+
+    An empty string was chosen to represent a 'falsy' value, but removing the
+    key has the same effect. May use either interchangeably
+    """
+
+    config = {
+        "tim-type": "pro",
+        "federation_list_url": "https://localhost:8080",
+        "federation_localization_url": "https://localhost:8000/localization",
+        "federation_list_client_cert": "tests/certs/client.pem",
+        "gematik_ca_baseurl": "https://download-ref.tsl.ti-dienste.de/",
+    }
+
+    def test_tim_type_is_not_case_sensitive(self) -> None:
+        test_config = self.config.copy()
+        test_config.update({"tim-type": "ePA"})
+        assert InviteChecker.parse_config(test_config).tim_type == TimType.EPA
+
+    def test_tim_type_defaults_to_pro_mode(self) -> None:
+        test_config = self.config.copy()
+        test_config.pop("tim-type")
+        assert InviteChecker.parse_config(test_config).tim_type == TimType.PRO
+
+    def test_incorrect_tim_type_raises(self) -> None:
+        test_config = self.config.copy()
+        test_config.update({"tim-type": "fake"})
+        self.assertRaises(ConfigError, InviteChecker.parse_config, test_config)
diff --git a/tests/test_local_invites.py b/tests/test_local_invites.py
index 4341cca..8988abd 100644
--- a/tests/test_local_invites.py
+++ b/tests/test_local_invites.py
@@ -12,6 +12,8 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
+from typing import Any
+
 from synapse.server import HomeServer
 from synapse.util import Clock
 from twisted.internet.testing import MemoryReactor
@@ -19,14 +21,25 @@
 from tests.base import ModuleApiTestCase
 
 
-class LocalInviteTest(ModuleApiTestCase):
+class LocalProModeInviteTest(ModuleApiTestCase):
+    """
+    These tests do not cover invites during room creation.
+    """
+
     def prepare(self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer):
         super().prepare(reactor, clock, homeserver)
+        #  @a:test is a practitioner
+        #  @b:test is an organization
+        #  @c:test is an 'orgPract'
         self.user_a = self.register_user("a", "password")
         self.access_token = self.login("a", "password")
         self.user_b = self.register_user("b", "password")
         self.user_c = self.register_user("c", "password")
 
+        # @d:test is none of those types of actor and should be just a 'User'. For
+        # context, this could be a chatbot or an office manager
+        self.user_d = self.register_user("d", "password")
+
         # authenticated as user_a
         self.helper.auth_user_id = self.user_a
 
@@ -35,7 +48,7 @@ def test_invite_to_dm(self) -> None:
         room_id = self.helper.create_room_as(
             self.user_a, is_public=False, tok=self.access_token
         )
-        assert room_id, "Room created"
+        assert room_id, "Room not created"
 
         # create DM event
         channel = self.make_request(
@@ -56,6 +69,13 @@ def test_invite_to_dm(self) -> None:
             tok=self.access_token,
             expect_code=403,
         )
+        self.helper.invite(
+            room=room_id,
+            src=self.user_a,
+            targ=self.user_d,
+            tok=self.access_token,
+            expect_code=403,
+        )
         # But can invite the dm user
         self.helper.invite(
             room=room_id,
@@ -70,7 +90,7 @@ def test_invite_to_group(self) -> None:
         room_id = self.helper.create_room_as(
             self.user_a, is_public=False, tok=self.access_token
         )
-        assert room_id, "Room created"
+        assert room_id, "Room not created"
 
         # create DM event
         channel = self.make_request(
@@ -98,13 +118,20 @@ def test_invite_to_group(self) -> None:
             tok=self.access_token,
             expect_code=200,
         )
+        self.helper.invite(
+            room=room_id,
+            src=self.user_a,
+            targ=self.user_d,
+            tok=self.access_token,
+            expect_code=200,
+        )
 
     def test_invite_to_group_without_dm_event(self) -> None:
         """Tests that a group with local users works normally in case the user has no m.direct set"""
         room_id = self.helper.create_room_as(
             self.user_a, is_public=False, tok=self.access_token
         )
-        assert room_id, "Room created"
+        assert room_id, "Room not created"
 
         # Can invite other users
         self.helper.invite(
@@ -121,3 +148,141 @@ def test_invite_to_group_without_dm_event(self) -> None:
             tok=self.access_token,
             expect_code=200,
         )
+        self.helper.invite(
+            room=room_id,
+            src=self.user_a,
+            targ=self.user_d,
+            tok=self.access_token,
+            expect_code=200,
+        )
+
+
+class LocalEpaModeInviteTest(ModuleApiTestCase):
+    """
+    These tests do not cover invites during room creation.
+
+        NOTE: This should not be allowed to work. Strictly speaking, a server that is
+    in 'epa' mode should always appear on the federation list as an 'isInsurance'.
+    For the moment, all we do is log a warning. This will be changed in the future
+    which will require assuming the identity of an insurance domain to test with.
+
+    """
+
+    def prepare(self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer):
+        super().prepare(reactor, clock, homeserver)
+        # Can't use any of:
+        #  @a:test is a practitioner
+        #  @b:test is an organization
+        #  @c:test is an 'orgPract'
+        # as they should not exist on an 'ePA' mode server backend
+
+        # 'd', 'e' and 'f' is none of those types of actor and should be just regular 'User's
+        self.user_d = self.register_user("d", "password")
+        self.user_e = self.register_user("e", "password")
+        self.user_f = self.register_user("f", "password")
+        self.access_token = self.login("d", "password")
+
+        # authenticated as user_d
+        self.helper.auth_user_id = self.user_d
+
+    def default_config(self) -> dict[str, Any]:
+        conf = super().default_config()
+        assert "modules" in conf, "modules missing from config dict during construction"
+
+        # There should only be a single item in the 'modules' list, since this tests that module
+        assert len(conf["modules"]) == 1, "more than one module found in config"
+
+        conf["modules"][0].setdefault("config", {}).update({"tim-type": "epa"})
+        return conf
+
+    def test_invite_to_dm_post_room_creation(self) -> None:
+        """Tests that a private room as a dm will deny inviting any local users"""
+        room_id = self.helper.create_room_as(
+            self.user_d, is_public=False, tok=self.access_token
+        )
+        assert room_id, "Room not created"
+
+        # create DM event
+        channel = self.make_request(
+            "PUT",
+            f"/user/{self.user_d}/account_data/m.direct",
+            {
+                self.user_e: [room_id],
+            },
+            access_token=self.access_token,
+        )
+        assert channel.code == 200, channel.result
+
+        # Can't invite other users
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_f,
+            tok=self.access_token,
+            expect_code=403,
+        )
+
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_e,
+            tok=self.access_token,
+            expect_code=403,
+        )
+
+    def test_invite_to_group_post_room_creation(self) -> None:
+        """Tests that a private room for a group will deny inviting any local users, with an unrelated m.direct tag"""
+        room_id = self.helper.create_room_as(
+            self.user_d, is_public=False, tok=self.access_token
+        )
+        assert room_id, "Room not created"
+
+        # create DM event
+        channel = self.make_request(
+            "PUT",
+            f"/user/{self.user_d}/account_data/m.direct",
+            {
+                self.user_e: ["!not:existing.example.com"],
+            },
+            access_token=self.access_token,
+        )
+        assert channel.code == 200, channel.result
+
+        # Can't invite other users
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_f,
+            tok=self.access_token,
+            expect_code=403,
+        )
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_e,
+            tok=self.access_token,
+            expect_code=403,
+        )
+
+    def test_invite_to_group_without_dm_event_post_room_creation(self) -> None:
+        """Tests that a group with local users is denied when the user has no m.direct set"""
+        room_id = self.helper.create_room_as(
+            self.user_d, is_public=False, tok=self.access_token
+        )
+        assert room_id, "Room not created"
+
+        # Can't invite other users
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_f,
+            tok=self.access_token,
+            expect_code=403,
+        )
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_e,
+            tok=self.access_token,
+            expect_code=403,
+        )
diff --git a/tests/test_remote_invites.py b/tests/test_remote_invites.py
index 01b69d6..d6a79f2 100644
--- a/tests/test_remote_invites.py
+++ b/tests/test_remote_invites.py
@@ -12,6 +12,8 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
+from typing import Any
+
 from synapse.module_api import NOT_SPAM, errors
 from synapse.server import HomeServer
 from synapse.util import Clock
@@ -19,17 +21,27 @@
 from twisted.internet.testing import MemoryReactor
 
 from tests.base import ModuleApiTestCase
-from tests.test_utils import DOMAIN_IN_LIST
+from tests.test_utils import DOMAIN_IN_LIST, INSURANCE_DOMAIN_IN_LIST
+
 
+class RemoteProModeInviteTest(ModuleApiTestCase):
+    """Test remote invites in the default 'pro' mode behave as expected."""
 
-class RemoteInviteTest(ModuleApiTestCase):
     def prepare(self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer):
         super().prepare(reactor, clock, homeserver)
+        #  @a:test is a practitioner
+        #  @b:test is an organization
+        #  @c:test is an 'orgPract'
         self.user_a = self.register_user("a", "password")
         self.access_token = self.login("a", "password")
         self.user_b = self.register_user("b", "password")
         self.user_c = self.register_user("c", "password")
 
+        # @d:test is none of those types of actor and should be just a 'User'. For
+        # context, this could be a chatbot or an office manager
+        self.user_d = self.register_user("d", "password")
+        self.access_token_d = self.login("d", "password")
+
         # authenticated as user_a
         self.helper.auth_user_id = self.user_a
 
@@ -76,37 +88,96 @@ async def test_invite_from_remote_outside_of_fed_list(self) -> None:
         )
         assert channel.code == 200, channel.result
 
+        # 'pract' sequence of tests. The practitioner tested is publicly listed and
+        # therefore doesn't need to have contact details
         assert (
             await self.may_invite(
                 f"@example:not-{DOMAIN_IN_LIST}", self.user_a, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
-        )
-        # currently not testing modifying the fed list atm
+        ), f"inviter '@example:not-{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_a} when not on fed list but DOES have contact details"
         assert (
             await self.may_invite(
                 "@example:messenger.spilikin.dev", self.user_a, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
-        )
+        ), f"inviter '@example:messenger.spilikin.dev' incorrectly ALLOWED to invite {self.user_a} without contact details"
         assert (
             await self.may_invite(
                 f"@example:{DOMAIN_IN_LIST}", self.user_a, "!madeup:example.com"
             )
             == NOT_SPAM
-        )
+        ), f"inviter '@example:{DOMAIN_IN_LIST}' incorrectly FORBIDDEN to invite {self.user_a} despite contact details"
         assert (
             await self.may_invite(
                 f"@example2:not-{DOMAIN_IN_LIST}", self.user_a, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
-        )
+        ), f"inviter '@example2:not-{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_a}"
         assert (
             await self.may_invite(
                 f"@example2:{DOMAIN_IN_LIST}", self.user_a, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
+        ), f"inviter '@example2:{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_a} without contact details"
+
+        # 'User' sequence of tests. Should not succeed without contact details of other
+        # party if they are on the fed list
+        assert (
+            await self.may_invite(
+                f"@example:not-{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example:not-{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_d} when not on fed list but DOES have contact details"
+        assert (
+            await self.may_invite(
+                "@example:messenger.spilikin.dev", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example:messenger.spilikin.dev' incorrectly ALLOWED to invite {self.user_d}"
+
+        assert (
+            await self.may_invite(
+                f"@example2:not-{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example2:not-{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_d}"
+        assert (
+            await self.may_invite(
+                f"@example2:{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example2:{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_d} without contact details"
+
+        # This single test should pass, but doesn't because 'd' has blocked all contacts
+        # unless added to their contact permissions. Do this below to test again
+        assert (
+            await self.may_invite(
+                f"@example:{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example:{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_d} without contact details"
+
+        channel = self.make_request(
+            "PUT",
+            "/_synapse/client/com.famedly/tim/v1/contacts",
+            {
+                "displayName": "Test User",
+                "mxid": f"@example:{DOMAIN_IN_LIST}",
+                "inviteSettings": {
+                    "start": 0,
+                },
+            },
+            access_token=self.access_token_d,
         )
+        assert channel.code == 200, channel.result
+        # Now it should work
+        assert (
+            await self.may_invite(
+                f"@example:{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == NOT_SPAM
+        ), f"inviter '@example:{DOMAIN_IN_LIST}' incorrectly FORBIDDEN to invite {self.user_d} despite contact details"
 
     async def test_invite_from_publicly_listed_practitioners(self) -> None:
         """Tests that an invite from a remote server gets accepted when in the federation list and both practitioners are public"""
@@ -125,11 +196,15 @@ async def test_invite_from_publicly_listed_practitioners(self) -> None:
             assert (
                 await self.may_invite(inviter, self.user_a, "!madeup:example.com")
                 == NOT_SPAM
-            )
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_a}"
             assert (
                 await self.may_invite(inviter, self.user_c, "!madeup:example.com")
                 == NOT_SPAM
-            )
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_c}"
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d} without contact details"
 
         for inviter in {
             f"@mxid404:{DOMAIN_IN_LIST}",
@@ -141,10 +216,15 @@ async def test_invite_from_publicly_listed_practitioners(self) -> None:
             assert (
                 await self.may_invite(inviter, self.user_a, "!madeup:example.com")
                 == errors.Codes.FORBIDDEN
-            )
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_a}"
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d} without contact details"
 
-    async def test_invite_from_remote_to_local_org(self) -> None:
-        """Tests that an invite from a remote server gets accepted when in the federation list and the invite is to an orgPract"""
+    async def test_invite_from_remote_to_local_org_various(self) -> None:
+        """Tests that an invite from a remote server gets accepted when in the
+        federation list and the invite is to an org or orgPract"""
         for inviter in {
             f"@mxid:{DOMAIN_IN_LIST}",
             f"@matrixuri:{DOMAIN_IN_LIST}",
@@ -162,24 +242,268 @@ async def test_invite_from_remote_to_local_org(self) -> None:
             f"@gematikuri404:{DOMAIN_IN_LIST}",
             f"@gematikuri2404:{DOMAIN_IN_LIST}",
         }:
+            # user 'b' is actually an 'org' not an 'orgPract'
             assert (
                 await self.may_invite(inviter, self.user_b, "!madeup:example.com")
                 == NOT_SPAM
-            )
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_b}"
             assert (
                 await self.may_invite(inviter, self.user_c, "!madeup:example.com")
                 == NOT_SPAM
-            )
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_c}"
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d} without contact details"
 
         assert (
             await self.may_invite(
                 "@unknown:not.in.fed", self.user_b, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
-        )
+        ), f"inviter '@unknown:not.in.fed' incorrectly ALLOWED from {self.user_b}"
         assert (
             await self.may_invite(
                 "@unknown:not.in.fed", self.user_c, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
+        ), f"inviter '@unknown:not.in.fed' incorrectly ALLOWED from {self.user_c}"
+        assert (
+            await self.may_invite(
+                "@unknown:not.in.fed", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@unknown:not.in.fed' incorrectly ALLOWED from {self.user_d}"
+
+    async def test_remote_invite_from_an_insurance_domain(self) -> None:
+        """
+        Test that an insured user can invite a publicly listed practitioner or organization
+        (but not a regular user on the practitioner's domain)
+        """
+        for inviter in {
+            f"@unknown:{INSURANCE_DOMAIN_IN_LIST}",
+            f"@rando-32-b52:{INSURANCE_DOMAIN_IN_LIST}",
+        }:
+            assert (
+                await self.may_invite(inviter, self.user_b, "!madeup:example.com")
+                == NOT_SPAM
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_a}"
+            assert (
+                await self.may_invite(inviter, self.user_c, "!madeup:example.com")
+                == NOT_SPAM
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_b}"
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d} without contact details"
+
+
+class RemoteEpaModeInviteTest(ModuleApiTestCase):
+    """
+    Test remote invites in 'epa' mode have expected behavior.
+
+    Note that if the local server is in 'epa' mode, it means the server 'isInsurance'.
+    Therefore, it is the responsibility of the remote server to deny *our* invites.
+    Likewise, it is our responsibility to deny *theirs* if they are also 'isInsurance'.
+
+    The second behavior is what we test here
+
+        NOTE: This should not be allowed to work. Strictly speaking, a server that is
+    in 'epa' mode should always appear on the federation list as an 'isInsurance'.
+    For the moment, all we do is log a warning. This will be changed in the future
+    which will require assuming the identity of an insurance domain to test with.
+
+    """
+
+    def prepare(self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer):
+        super().prepare(reactor, clock, homeserver)
+        # Can't use any of:
+        #  @a:test is a practitioner
+        #  @b:test is an organization
+        #  @c:test is an 'orgPract'
+        # as they should not exist on an 'ePA' mode server backend
+
+        # 'd', 'e' and 'f' is none of those types of actor and should be just regular 'User's
+        self.user_d = self.register_user("d", "password")
+        self.access_token_d = self.login("d", "password")
+
+        # authenticated as user_d
+        self.helper.auth_user_id = self.user_d
+
+    def default_config(self) -> dict[str, Any]:
+        conf = super().default_config()
+        assert "modules" in conf, "modules missing from config dict during construction"
+
+        # There should only be a single item in the 'modules' list, since this tests that module
+        assert len(conf["modules"]) == 1, "more than one module found in config"
+
+        conf["modules"][0].setdefault("config", {}).update({"tim-type": "epa"})
+        return conf
+
+    async def may_invite(self, inviter: str, invitee: str, roomid: str):
+        req = defer.ensureDeferred(
+            self.hs.get_module_api()._callbacks.spam_checker.user_may_invite(
+                inviter, invitee, roomid
+            )
         )
+        self.wait_on_thread(req)
+        ret = self.get_success(req)
+        if ret == NOT_SPAM:
+            return NOT_SPAM
+        return ret[0]  # return first code instead of all of them to make assert easier
+
+    async def test_invite_from_remote_not_on_fed_list(self) -> None:
+        """Tests that an invite from a remote server not in the federation list gets denied"""
+
+        channel = self.make_request(
+            "PUT",
+            "/_synapse/client/com.famedly/tim/v1/contacts",
+            {
+                "displayName": "Test User",
+                "mxid": f"@example:{DOMAIN_IN_LIST}",
+                "inviteSettings": {
+                    "start": 0,
+                },
+            },
+            access_token=self.access_token_d,
+        )
+        assert channel.code == 200, channel.result
+
+        channel = self.make_request(
+            "PUT",
+            "/_synapse/client/com.famedly/tim/v1/contacts",
+            {
+                "displayName": "Test User",
+                "mxid": f"@example:not-{DOMAIN_IN_LIST}",
+                "inviteSettings": {
+                    "start": 0,
+                },
+            },
+            access_token=self.access_token_d,
+        )
+        assert channel.code == 200, channel.result
+
+        for inviter in {
+            f"@example:not-{DOMAIN_IN_LIST}",
+            "@example:messenger.spilikin.dev",
+            f"@example2:not-{DOMAIN_IN_LIST}",
+            f"@example2:{DOMAIN_IN_LIST}",
+        }:
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter '{inviter}' incorrectly ALLOWED to invite {self.user_d}"
+
+    async def test_invite_from_remote_on_fed_list(self) -> None:
+        """Test that a remote user invite from a domain on the fed list only succeeds with contact details"""
+        assert (
+            await self.may_invite(
+                f"@example:{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example:{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_d}"
+
+        # Now add in the contact details and try again
+        channel = self.make_request(
+            "PUT",
+            "/_synapse/client/com.famedly/tim/v1/contacts",
+            {
+                "displayName": "Test User",
+                "mxid": f"@example:{DOMAIN_IN_LIST}",
+                "inviteSettings": {
+                    "start": 0,
+                },
+            },
+            access_token=self.access_token_d,
+        )
+        assert channel.code == 200, channel.result
+
+        assert (
+            await self.may_invite(
+                f"@example:{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == NOT_SPAM
+        ), f"inviter '@example:{DOMAIN_IN_LIST}' incorrectly FORBIDDEN to invite {self.user_d}"
+
+    async def test_invite_from_remote_practitioners(self) -> None:
+        """
+        Tests that an invite from a remote server gets accepted when in the federation
+        list, and it is not 'isInsurance'. Borrow our localization setup for this
+        """
+        for inviter in {
+            f"@mxid:{DOMAIN_IN_LIST}",
+            f"@matrixuri:{DOMAIN_IN_LIST}",
+            f"@matrixuri2:{DOMAIN_IN_LIST}",
+            f"@gematikuri:{DOMAIN_IN_LIST}",
+            f"@gematikuri2:{DOMAIN_IN_LIST}",
+            f"@mxidorgpract:{DOMAIN_IN_LIST}",
+            f"@matrixuriorgpract:{DOMAIN_IN_LIST}",
+            f"@matrixuri2orgpract:{DOMAIN_IN_LIST}",
+            f"@gematikuriorgpract:{DOMAIN_IN_LIST}",
+            f"@gematikuri2orgpract:{DOMAIN_IN_LIST}",
+            f"@mxid404:{DOMAIN_IN_LIST}",
+            f"@matrixuri404:{DOMAIN_IN_LIST}",
+            f"@matrixuri2404:{DOMAIN_IN_LIST}",
+            f"@gematikuri404:{DOMAIN_IN_LIST}",
+            f"@gematikuri2404:{DOMAIN_IN_LIST}",
+        }:
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d}(step one)"
+
+            # Now add in the contact details...
+            channel = self.make_request(
+                "PUT",
+                "/_synapse/client/com.famedly/tim/v1/contacts",
+                {
+                    "displayName": "Test User",
+                    "mxid": f"{inviter}",
+                    "inviteSettings": {
+                        "start": 0,
+                    },
+                },
+                access_token=self.access_token_d,
+            )
+            assert channel.code == 200, channel.result
+
+            # ...and try again
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == NOT_SPAM
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_d}(step two)"
+
+    async def test_remote_invite_from_an_insured_domain_fails(self) -> None:
+        """
+        Test that invites from another insurance domain are rejected with or without
+        contact permissions
+        """
+        for inviter in {
+            f"@unknown:{INSURANCE_DOMAIN_IN_LIST}",
+            f"@rando-32-b52:{INSURANCE_DOMAIN_IN_LIST}",
+        }:
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d}"
+
+            # Now add in the contact details...
+            channel = self.make_request(
+                "PUT",
+                "/_synapse/client/com.famedly/tim/v1/contacts",
+                {
+                    "displayName": "Test User",
+                    "mxid": f"{inviter}",
+                    "inviteSettings": {
+                        "start": 0,
+                    },
+                },
+                access_token=self.access_token_d,
+            )
+            assert channel.code == 200, channel.result
+
+            # ...and try again
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d}"
diff --git a/tests/test_tim_federation.py b/tests/test_tim_federation.py
index e4c2bd6..2339b2b 100644
--- a/tests/test_tim_federation.py
+++ b/tests/test_tim_federation.py
@@ -19,6 +19,7 @@
 from synapse_invite_checker import InviteChecker
 from synapse_invite_checker.types import FederationDomain
 from tests.base import ModuleApiTestCase
+from tests.test_utils import DOMAIN_IN_LIST
 
 
 class FederationDomainSchemaTest(ModuleApiTestCase):
@@ -47,12 +48,13 @@ def prepare(
 
     async def extract_entry_from_domainList(self, domain: str) -> FederationDomain:
         """
-        Search for a specific domain in the federation list
+        Search for a specific domain in the federation list to extract it's associated
+        data. Normally this additional information is not used
         """
-        _, raw_fed_list = await self.invchecker.fetch_federation_allow_list()
-        assert len(raw_fed_list.domainList) > 0
+        fed_list = await self.invchecker._fetch_federation_list()
+        assert len(fed_list.domainList) > 0
 
-        for domain_entry in raw_fed_list.domainList:
+        for domain_entry in fed_list.domainList:
             if domain_entry.domain == domain:
                 return domain_entry
 
@@ -61,8 +63,39 @@ async def extract_entry_from_domainList(self, domain: str) -> FederationDomain:
     async def test_federation_list(self) -> None:
         """Ensure we can properly fetch the federation list"""
 
-        domains, _ = await self.invchecker.fetch_federation_allow_list()
-        assert "timo.staging.famedly.de" in domains
+        fed_list = await self.invchecker._fetch_federation_list()
+        assert fed_list.allowed("timo.staging.famedly.de")
+
+    async def test_is_insurance(self) -> None:
+        """Ensure we can properly determine if a domain is insurance"""
+
+        fed_list = await self.invchecker._fetch_federation_list()
+        assert fed_list.is_insurance("cirosec.de")
+
+    async def test_is_domain_allowed_uses_cache(self) -> None:
+        # Simple procedural test. It does not increase coverage(at the time it was
+        # written), but did prove to me that it was working.
+        # 1. clear the count on the mock of called instances
+        # 2. get the value, which should increase counter to 1
+        # 3. check that calls == 1, reset mock to 0
+        # 4. get the value again, which should retrieve from cache and have a count of 0
+        self.invchecker._raw_federation_list_fetch.reset_mock()
+
+        self.invchecker._raw_federation_list_fetch.assert_not_called()
+
+        should_be_true = await self.invchecker.is_domain_allowed(DOMAIN_IN_LIST)
+        assert should_be_true, "tested domain was not allowed but should have been"
+
+        self.invchecker._raw_federation_list_fetch.assert_called_once()
+
+        self.invchecker._raw_federation_list_fetch.reset_mock()
+
+        should_still_be_true = await self.invchecker.is_domain_allowed(DOMAIN_IN_LIST)
+        assert (
+            should_still_be_true
+        ), "tested domain was still not allowed but should have been"
+
+        self.invchecker._raw_federation_list_fetch.assert_not_called()
 
     async def test_common_fed_domain(self):
         # First test the most common FederationDomain entry
diff --git a/tests/test_tim_information.py b/tests/test_tim_information.py
index 4a1a40a..8cbd147 100644
--- a/tests/test_tim_information.py
+++ b/tests/test_tim_information.py
@@ -62,3 +62,63 @@ def test_custom_operator_contact_info_resource(self) -> None:
         assert channel.json_body["description"] == "def"
         assert channel.json_body["contact"] == "ghi"
         assert channel.json_body["version"], "Version returned"
+
+
+class MessengerIsInsuranceResourceTest(ModuleApiTestCase):
+    def test_pro_isInsurance_returns_expected(self) -> None:
+        """Tests that Pro mode returns expected response"""
+        channel = self.make_request(
+            method="GET",
+            path="/_synapse/client/com.famedly/tim/tim-information/v1/server/isInsurance?serverName=cirosec.de",
+        )
+
+        assert channel.code == 200, channel.result
+        assert channel.json_body[
+            "isInsurance"
+        ], "isInsurance is FALSE when it should be TRUE"
+
+        channel = self.make_request(
+            method="GET",
+            path="/_synapse/client/com.famedly/tim/tim-information/v1/server/isInsurance?serverName=timo.staging.famedly.de",
+        )
+
+        assert channel.code == 200, channel.result
+        assert not channel.json_body[
+            "isInsurance"
+        ], "isInsurance is TRUE when it should be FALSE"
+
+    @synapse_test.override_config(
+        {
+            "modules": [
+                {
+                    "module": "synapse_invite_checker.InviteChecker",
+                    "config": {
+                        "tim-type": "epa",
+                        "title": "abc",
+                        "description": "def",
+                        "contact": "ghi",
+                        "federation_list_url": "https://localhost:8080",
+                        "federation_localization_url": "https://localhost:8000/localization",
+                        "federation_list_client_cert": "tests/certs/client.pem",
+                        "gematik_ca_baseurl": "https://download-ref.tsl.ti-dienste.de/",
+                    },
+                }
+            ]
+        }
+    )
+    def test_epa_isInsurance_returns_expected(self) -> None:
+        """Tests that ePA mode returns expected response"""
+
+        channel = self.make_request(
+            method="GET",
+            path="/_synapse/client/com.famedly/tim/tim-information/v1/server/isInsurance?serverName=cirosec.de",
+        )
+
+        assert channel.code == 401, channel.result
+
+        channel = self.make_request(
+            method="GET",
+            path="/_synapse/client/com.famedly/tim/tim-information/v1/server/isInsurance?serverName=timo.staging.famedly.de",
+        )
+
+        assert channel.code == 401, channel.result
diff --git a/tests/test_utils/__init__.py b/tests/test_utils/__init__.py
index dbbee5d..71a3143 100644
--- a/tests/test_utils/__init__.py
+++ b/tests/test_utils/__init__.py
@@ -40,6 +40,8 @@
 
 # Use a domain found in our raw fedlist data instead of inserting a fake one
 DOMAIN_IN_LIST = "timo.staging.famedly.de"
+# Also borrow an 'isInsurance' domain for testing
+INSURANCE_DOMAIN_IN_LIST = "cirosec.de"
 
 
 def get_awaitable_result(awaitable: Awaitable[TV]) -> TV: