Repository: aio-libs/aiosmtpd

Issue:
aiosmtpd.smtp.SMTP(…) erroneously modifies ssl.SSLContext configuration
The initialisation of the `aiosmtpd.smtp.SMTP` class erroneously [modifies](https://github.com/aio-libs/aiosmtpd/blob/f3d04f9ae79cc72dbc68dbb1d8f638070c8033ec/aiosmtpd/smtp.py#L96-L100) the configuration of the [ssl.SSLContext](https://docs.python.org/3/library/ssl.html#ssl.SSLContext) passed to it as the `tls_context` parameter. The modifications made to the provided `ssl.SSLContext` are both unexpected and incorrect and therefore should be removed.

Setting the [ssl.SSLContext.check_hostname](https://docs.python.org/3/library/ssl.html#ssl.SSLContext.check_hostname) value to `False` is irrelevant because it is only applicable to _client-side_ sockets (i.e. [ssl.SSLSocket.server_side](https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.server_side) is `False`) whereas the `aiosmtpd.smtp.SMTP` implementation manages _server-side_ sockets (i.e. [server_side=True](https://github.com/aio-libs/aiosmtpd/blob/f3d04f9ae79cc72dbc68dbb1d8f638070c8033ec/aiosmtpd/smtp.py#L409)).

Setting the [ssl.SSLContext.verify_mode](https://docs.python.org/3/library/ssl.html#ssl.SSLContext.verify_mode) to [ssl.CERT_NONE](https://docs.python.org/3/library/ssl.html#ssl.CERT_NONE) prohibits the use of TLS client certificates for client authentication (i.e. forcibly overwrites any previously configured value of [ssl.CERT_OPTIONAL](https://docs.python.org/3/library/ssl.html#ssl.CERT_OPTIONAL)) because:

> In server mode, no certificate is requested from the client, so the client does not send any for client cert authentication.

The comment regarding [RFC3207 part 4.1](https://tools.ietf.org/html/rfc3207#section-4.1) seems misguided, the RFC appears to suggest that the level of security/authenticity established via the TLS handshake, initiated by the issuing of the "STARTTLS" SMTP command, is a matter for the implementation; rather than that TLS client certificates should not be sought from clients by servers.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196?src=pr&el=h1) Report
> Merging [#196](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196?src=pr&el=desc) into [master](https://codecov.io/gh/aio-libs/aiosmtpd/commit/fb5c4bf889ec5aa11068e40f711f2446bd532f17?el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196/graphs/tree.svg?width=650&height=150&src=pr&token=k9dc7NVG69)](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##            master      #196    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files            5         5            
  Lines          819       955   +136     
  Branches       132       170    +38     
==========================================
+ Hits           819       955   +136     
```


| [Impacted Files](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [aiosmtpd/controller.py](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196/diff?src=pr&el=tree#diff-YWlvc210cGQvY29udHJvbGxlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [aiosmtpd/handlers.py](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196/diff?src=pr&el=tree#diff-YWlvc210cGQvaGFuZGxlcnMucHk=) | `100.00% <100.00%> (ø)` | |
| [aiosmtpd/main.py](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196/diff?src=pr&el=tree#diff-YWlvc210cGQvbWFpbi5weQ==) | `100.00% <100.00%> (ø)` | |
| [aiosmtpd/smtp.py](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196/diff?src=pr&el=tree#diff-YWlvc210cGQvc210cC5weQ==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196?src=pr&el=footer). Last update [2973c41...a87e628](https://codecov.io/gh/aio-libs/aiosmtpd/pull/196?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

pepoluan: As usual, Travis failed covering that particular spot. After LGTM.com finishes, I'll close & reopen.
CLAassistant: [![CLA assistant check](https://cla-assistant.io/pull/badge/not_signed)](https://cla-assistant.io/aio-libs/aiosmtpd?pullRequest=196) <br/>Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our [Contributor License Agreement](https://cla-assistant.io/aio-libs/aiosmtpd?pullRequest=196) before we can accept your contribution.<br/><sub>You have signed the CLA already but the status is still pending? Let us [recheck](https://cla-assistant.io/check/aio-libs/aiosmtpd?pullRequest=196) it.</sub>
pepoluan: I change this to "draft".

Will "undraft" after testing on my systems.
pepoluan: Tested on all my testing systems, and I'm confident enough to undraft this.
pepoluan: > 
> 
> 👍 I dig it

Awesome 😊

If no objections within the next 24h, I'll squamerge this.
waynew: Looks like some conflicts popped up but I'm guessing they're straightforward
pepoluan: > 
> 
> Looks like some conflicts popped up but I'm guessing they're straightforward

Eheheh, fixed 😄

Patch:
diff --git a/.coveragerc b/.coveragerc
index 1c2cd01..65c4d18 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -5,10 +5,7 @@ omit =
      setup*
      aiosmtpd/testing/*
      aiosmtpd/tests/*
-    .tox/*/lib/python3.*/site-packages/*
-    .tox/*/Lib/site-packages/*
-    .tox/Lib/site-packages/*
-    .tox/*/site-packages/*
+    .tox/*
 
 [paths]
 source =
diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst
index 181277e..df59aaa 100644
--- a/aiosmtpd/docs/NEWS.rst
+++ b/aiosmtpd/docs/NEWS.rst
@@ -5,12 +5,17 @@
 1.2.3 (aiosmtpd-next)
 =====================
 
+Added
+-----
+* Test for ``SMTP.__init__`` behavior after taking out code that edits TLS Context
+
 Fixed/Improved
 --------------
 * ``handle_exception()`` no longer gets called when the client disconnected (Closes #127, #162)
 * Implement & enforce line-length-limit, thus becoming Compliant with RFC 5321 § 4.5.3.1.6
 * Delay all SMTP Status Code replies during ``DATA`` phase until the phase termination (Closes #9)
 * Now catches ``Controller.factory()`` failure during ``Controller.start()`` (Closes #212)
+* :class:`SMTP` no longer edits user-supplied SSL Context (closes #191)
 
 
 1.2.2 (2020-11-08)
diff --git a/aiosmtpd/lmtp.py b/aiosmtpd/lmtp.py
index 53ed087..8d763fd 100644
--- a/aiosmtpd/lmtp.py
+++ b/aiosmtpd/lmtp.py
@@ -4,11 +4,12 @@ from public import public
 
 @public
 class LMTP(SMTP):
+    show_smtp_greeting = False
+
     @syntax('LHLO hostname')
     async def smtp_LHLO(self, arg):
         """The LMTP greeting, used instead of HELO/EHLO."""
         await super().smtp_HELO(arg)
-        self.show_smtp_greeting = False
 
     async def smtp_HELO(self, arg):
         """HELO is not a valid LMTP command."""
diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py
index 38c20a9..b45b539 100644
--- a/aiosmtpd/smtp.py
+++ b/aiosmtpd/smtp.py
@@ -127,7 +127,7 @@ class SMTP(asyncio.StreamReaderProtocol):
             decode_data=False,
             hostname=None,
             ident=None,
-            tls_context=None,
+            tls_context: Optional[ssl.SSLContext] = None,
             require_starttls=False,
             timeout=300,
             auth_required=False,
@@ -154,10 +154,14 @@ class SMTP(asyncio.StreamReaderProtocol):
             self.hostname = socket.getfqdn()
         self.tls_context = tls_context
         if tls_context:
-            # Through rfc3207 part 4.1 certificate checking is part of SMTP
-            # protocol, not SSL layer.
-            self.tls_context.check_hostname = False
-            self.tls_context.verify_mode = ssl.CERT_NONE
+            if (tls_context.verify_mode
+                    not in {ssl.CERT_NONE, ssl.CERT_OPTIONAL}):
+                log.warning("tls_context.verify_mode not in {CERT_NONE, "
+                            "CERT_OPTIONAL}; this might cause client "
+                            "connection problems")
+            elif tls_context.check_hostname:
+                log.warning("tls_context.check_hostname == True; "
+                            "this might cause client connection problems")
         self.require_starttls = tls_context and require_starttls
         self._timeout_duration = timeout
         self._timeout_handle = None
@@ -171,6 +175,7 @@ class SMTP(asyncio.StreamReaderProtocol):
         if not auth_require_tls and auth_required:
             warn("Requiring AUTH while not requiring TLS "
                  "can lead to security vulnerabilities!")
+            log.warning("auth_required == True but auth_require_tls == False")
         self._auth_require_tls = auth_require_tls
         self._auth_callback = auth_callback or login_always_fail
         self._auth_required = auth_required
diff --git a/housekeep.py b/housekeep.py
index d2c3993..f1e6acf 100644
--- a/housekeep.py
+++ b/housekeep.py
@@ -3,14 +3,30 @@
 import os
 import sys
 import pprint
+import inspect
 import argparse
 
 from pathlib import Path
 
+try:
+    # noinspection PyPackageRequirements
+    from colorama import (
+        Fore,
+        Style,
+        init as colorama_init,
+    )
+except ImportError:
+    colorama_init = None
+
+    class Fore:
+        CYAN = "\x1b[1;96m"
+        GREEN = "\x1b[1;92m"
+        YELLOW = "\x1b[1;93m"
+
+    class Style:
+        BRIGHT = "\x1b[1m"
+        RESET_ALL = "\x1b[0m"
 
-FG_CYAN = "\x1b[1;96m"
-BOLD = "\x1b[1m"
-NORM = "\x1b[0m"
 
 TOX_ENV_NAME = os.environ.get("TOX_ENV_NAME", None)
 
@@ -95,14 +111,14 @@ def pycache_clean(verbose=False):
 
 def rm_work():
     """Remove work dirs & files. They are .gitignore'd anyways."""
-    print(f"{BOLD}Removing work dirs ... {NORM}", end="")
+    print(f"{Style.BRIGHT}Removing work dirs ... ", end="")
     # The reason we list WORKDIRS explicitly is because we don't want to accidentally
     # bork IDE workdirs such as .idea/ or .vscode/
     for dd in WORKDIRS:
         print(dd, end="", flush=True)
         deldir(Path(dd))
         print(" ", end="", flush=True)
-    print(f"\n{BOLD}Removing work files ...{NORM}", end="")
+    print(f"\n{Style.BRIGHT}Removing work files ...", end="")
     for fn in (".coverage", "coverage.xml", "diffcov.html"):
         print(".", end="", flush=True)
         fp = Path(fn)
@@ -124,18 +140,33 @@ def rm_work():
 
 
 def dispatch_setup():
+    """
+    Set up work directories and dump env vars
+    """
     dump_env()
 
 
-def dispatch_cleanup():
+def dispatch_gather():
+    """
+    Gather inspection results into per-testenv dirs
+    """
     move_prof()
+
+
+def dispatch_remcache():
+    """
+    Remove all .py[co] files and all __pycache__ dirs
+    """
     pycache_clean()
 
 
 def dispatch_superclean():
+    """
+    Total cleaning of all test artifacts
+    """
     if TOX_ENV_NAME is not None:
         raise RuntimeError("Do NOT run this inside tox!")
-    print(f"{BOLD}Running pycache cleanup ...{NORM}", end="")
+    print(f"{Style.BRIGHT}Running pycache cleanup ...", end="")
     pycache_clean(verbose=True)
     rm_work()
 
@@ -144,26 +175,64 @@ def dispatch_superclean():
 
 
 def get_opts(argv):
+    # From: https://stackoverflow.com/a/49999185/149900
+    class NoAction(argparse.Action):
+        def __init__(self, **kwargs):
+            kwargs.setdefault("default", argparse.SUPPRESS)
+            kwargs.setdefault("nargs", 0)
+            super().__init__(**kwargs)
+
+        def __call__(self, *args, **kwargs):
+            pass
+
+    dispers = {
+        name.replace("dispatch_", ""): inspect.getdoc(obj)
+        for name, obj in inspect.getmembers(sys.modules[__name__])
+        if name.startswith("dispatch_") and inspect.isfunction(obj)
+    }
+
     parser = argparse.ArgumentParser()
+    parser.register("action", "no_action", NoAction)
+
     parser.add_argument(
-        "cmd", metavar="CMD", choices=["setup", "cleanup", "superclean"]
+        "--force", "-F", action="store_true", help="Force action even if in CI"
     )
+    parser.add_argument(
+        "cmd", metavar="COMMAND", choices=sorted(dispers.keys()), help="(See below)"
+    )
+
+    cgrp = parser.add_argument_group(title="COMMAND is one of")
+    for name, doc in sorted(dispers.items()):
+        cgrp.add_argument(name, help=doc, action="no_action")
+
     return parser.parse_args(argv)
 
 
 def python_interp_details():
-    print(f"{FG_CYAN}Python interpreter details:{NORM}")
+    print(f"{Fore.CYAN}Python interpreter details:")
     details = sys.version.splitlines() + sys.executable.splitlines()
     for ln in details:
-        print(f"    {FG_CYAN}{ln}{NORM}")
+        print(f"    {Fore.CYAN}{ln}")
+    print(Style.RESET_ALL, end="", flush=True)
 
 
 if __name__ == "__main__":
+    colorama_init is None or colorama_init(autoreset=True)
     python_interp_details()
-    if os.environ.get("CI") == "true":
-        # All the housekeeping steps are pointless on Travis CI / GitHub Actions;
-        # they build and tear down their VMs everytime anyways.
-        sys.exit(0)
     opts = get_opts(sys.argv[1:])
+    if os.environ.get("CI") == "true":
+        if not opts.force:
+            # All the housekeeping steps are pointless on Travis CI / GitHub Actions;
+            # they build and tear down their VMs everytime anyways.
+            print(
+                f"{Fore.YELLOW}Skipping housekeeping because we're in CI and "
+                f"--force not specified"
+            )
+            sys.exit(0)
+        else:
+            print(f"{Fore.YELLOW}We're in CI but --force is specified")
+    print(f"{Fore.GREEN}{Path(__file__).name} {opts.cmd}{Style.RESET_ALL}")
     dispatcher = globals().get(f"dispatch_{opts.cmd}")
     dispatcher()
+    # Defensive reset
+    print(Style.RESET_ALL, end="", flush=True)
diff --git a/tox.ini b/tox.ini
index 66a16b1..4bee309 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,6 +3,14 @@ envlist = qa, docs, py{36,37,38,39}-{nocov,cov,diffcov}
 skip_missing_interpreters = True
 
 [testenv]
+# One virtualenv per Python version
+envdir =
+    py36: {toxworkdir}/3.6
+    py37: {toxworkdir}/3.7
+    py38: {toxworkdir}/3.8
+    py39: {toxworkdir}/3.9
+    pypy3: {toxworkdir}/pypy3
+    py: {toxworkdir}/py
 commands =
     python housekeep.py setup
     nocov: python -m nose2 -v {posargs}
@@ -14,13 +22,13 @@ commands =
     diffcov: diff-cover coverage.xml --html-report diffcov.html
     diffcov: diff-cover coverage.xml --fail-under=100
     profile: python -m nose2 --profile {posargs}
-    python housekeep.py cleanup
+    python housekeep.py gather
 #sitepackages = True
 usedevelop = True
 deps =
-    !diffcov: nose2
-    cov: coverage
-    diffcov: diff_cover
+    nose2
+    coverage
+    diff_cover
 setenv =
     cov: COVERAGE_PROCESS_START=.coveragerc
     cov: COVERAGE_OPTIONS="-p"
@@ -42,19 +50,28 @@ passenv =
     CI
     GITHUB*
 
-[testenv:qa]
+[qadocs]
 basepython = python3
+envdir = {toxworkdir}/python3
+deps =
+    flake8
+    sphinx
+
+[testenv:qa]
+basepython = {[qadocs]basepython}
+envdir = {[qadocs]envdir}
 commands =
     python -m flake8 aiosmtpd
 deps =
-    flake8
+    {[qadocs]deps}
 
 [testenv:docs]
-basepython = python3
+basepython = {[qadocs]basepython}
+envdir = {[qadocs]envdir}
 commands =
     python setup.py build_sphinx
 deps:
-    sphinx
+    {[qadocs]deps}
 
 [flake8]
 exclude = conf.py


Test patch:
diff --git a/.github/workflows/unit-testing-and-coverage.yml b/.github/workflows/unit-testing-and-coverage.yml
index 6e02e2c..d3de80b 100644
--- a/.github/workflows/unit-testing-and-coverage.yml
+++ b/.github/workflows/unit-testing-and-coverage.yml
@@ -50,6 +50,8 @@ jobs:
     - name: "Execute tox"
       env:
         PLATFORM: ${{ matrix.platform }}
+      # "py" testenv means we'll use whatever Python provided by GA,
+      # as specified in 'matrix' above. No need to specify exact version.
       run: |
         tox -e "qa,docs,py-cov"
     - name: "Report to codecov"
diff --git a/aiosmtpd/testing/helpers.py b/aiosmtpd/testing/helpers.py
index 8b3af86..b35b4ae 100644
--- a/aiosmtpd/testing/helpers.py
+++ b/aiosmtpd/testing/helpers.py
@@ -1,5 +1,6 @@
 """Testing helpers."""
 
+import ssl
 import sys
 import select
 import socket
@@ -10,6 +11,7 @@ import warnings
 
 from contextlib import ExitStack
 from typing import List
+from pkg_resources import resource_filename
 from unittest import TestCase
 from unittest.mock import patch
 
@@ -100,3 +102,23 @@ def send_recv(
         else:
             break
     return b"".join(result)
+
+
+def get_server_context():
+    tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+    tls_context.load_cert_chain(
+        resource_filename('aiosmtpd.tests.certs', 'server.crt'),
+        resource_filename('aiosmtpd.tests.certs', 'server.key'),
+    )
+    return tls_context
+
+
+class ReceivingHandler:
+    box = None
+
+    def __init__(self):
+        self.box = []
+
+    async def handle_DATA(self, server, session, envelope):
+        self.box.append(envelope)
+        return '250 OK'
diff --git a/aiosmtpd/tests/test_main.py b/aiosmtpd/tests/test_main.py
index cd244c3..1416383 100644
--- a/aiosmtpd/tests/test_main.py
+++ b/aiosmtpd/tests/test_main.py
@@ -148,18 +148,20 @@ class TestMain(unittest.TestCase):
             main(('-n', '-d'))
             self.assertEqual(log.getEffectiveLevel(), logging.INFO)
 
-    def test_debug_2(self):
-        # Mock the logger to eliminate console noise.
-        with patch.object(logging.getLogger('mail.log'), 'info'):
-            main(('-n', '-dd'))
-            self.assertEqual(log.getEffectiveLevel(), logging.DEBUG)
-
-    def test_debug_3(self):
-        # Mock the logger to eliminate console noise.
-        with patch.object(logging.getLogger('mail.log'), 'info'):
-            main(('-n', '-ddd'))
-            self.assertEqual(log.getEffectiveLevel(), logging.DEBUG)
-            self.assertTrue(asyncio.get_event_loop().get_debug())
+    # Mock the logger to eliminate console noise.
+    @patch("logging.Logger.info")
+    @patch("logging.Logger.debug")
+    def test_debug_2(self, mock_debug, mock_info):
+        main(('-n', '-dd'))
+        self.assertEqual(log.getEffectiveLevel(), logging.DEBUG)
+
+    # Mock the logger to eliminate console noise.
+    @patch("logging.Logger.info")
+    @patch("logging.Logger.debug")
+    def test_debug_3(self, mock_debug, mock_info):
+        main(('-n', '-ddd'))
+        self.assertEqual(log.getEffectiveLevel(), logging.DEBUG)
+        self.assertTrue(asyncio.get_event_loop().get_debug())
 
 
 class TestParseArgs(unittest.TestCase):
diff --git a/aiosmtpd/tests/test_smtp.py b/aiosmtpd/tests/test_smtp.py
index cb530c2..9be169e 100644
--- a/aiosmtpd/tests/test_smtp.py
+++ b/aiosmtpd/tests/test_smtp.py
@@ -12,6 +12,7 @@ from aiosmtpd.smtp import (
     MISSING, Session as SMTPSess, SMTP as Server, __ident__ as GREETING
 )
 from aiosmtpd.testing.helpers import (
+    ReceivingHandler,
     SUPPORTED_COMMANDS_NOTLS,
     assert_auth_invalid,
     assert_auth_required,
@@ -119,17 +120,6 @@ class RequiredAuthDecodingController(Controller):
                       auth_required=True)
 
 
-class ReceivingHandler:
-    box = None
-
-    def __init__(self):
-        self.box = []
-
-    async def handle_DATA(self, server, session, envelope):
-        self.box.append(envelope)
-        return '250 OK'
-
-
 class StoreEnvelopeOnVRFYHandler:
     """Saves envelope for later inspection when handling VRFY."""
     envelope = None
@@ -1065,17 +1055,22 @@ class TestSMTPAuth(unittest.TestCase):
 
 class TestRequiredAuthentication(unittest.TestCase):
     def setUp(self):
-        controller = RequiredAuthDecodingController(Sink)
-        self.addCleanup(controller.stop)
-        controller.start()
-        self.address = (controller.hostname, controller.port)
-
         self.resource = ExitStack()
         self.addCleanup(self.resource.close)
+
         # Suppress auth_req_but_no_tls warning
         self.resource.enter_context(cast(ContextManager, warnings.catch_warnings()))
         warnings.simplefilter("ignore", category=UserWarning)
 
+        self.resource.enter_context(
+            cast(ContextManager, patch("logging.Logger.warning"))
+        )
+
+        controller = RequiredAuthDecodingController(Sink)
+        self.addCleanup(controller.stop)
+        controller.start()
+        self.address = (controller.hostname, controller.port)
+
     def test_help_unauthenticated(self):
         with SMTP(*self.address) as client:
             code, response = client.docmd('HELP')
@@ -1819,3 +1814,31 @@ class TestTimeout(unittest.TestCase):
             code, response = client.ehlo('example.com')
             time.sleep(0.1 + TimeoutController.Delay)
             self.assertRaises(SMTPServerDisconnected, client.getreply)
+
+
+class TestAuthArgs(unittest.TestCase):
+    @patch("logging.Logger.warning")
+    @patch("aiosmtpd.smtp.warn")
+    def test_warn_authreqnotls(self, mock_warn: Mock, mock_warning: Mock):
+        """If auth_required=True while auth_require_tls=False, emit warning"""
+        _ = Server(Sink(), auth_required=True, auth_require_tls=False)
+        mock_warn.assert_any_call(
+            "Requiring AUTH while not requiring TLS "
+            "can lead to security vulnerabilities!"
+        )
+        mock_warning.assert_any_call(
+            "auth_required == True but auth_require_tls == False"
+        )
+
+    @patch("logging.Logger.info")
+    def test_log_authmechanisms(self, mock_info: Mock):
+        """At __init__ list of AUTH mechanisms must be logged"""
+        server = Server(Sink())
+        auth_mechs = sorted(
+            m.replace("auth_", "") + "(builtin)"
+            for m in dir(server)
+            if m.startswith("auth_")
+        )
+        mock_info.assert_any_call(
+            f"Available AUTH mechanisms: {' '.join(auth_mechs)}"
+        )
diff --git a/aiosmtpd/tests/test_smtps.py b/aiosmtpd/tests/test_smtps.py
index e69b9cf..d34770d 100644
--- a/aiosmtpd/tests/test_smtps.py
+++ b/aiosmtpd/tests/test_smtps.py
@@ -8,6 +8,10 @@ import pkg_resources
 from aiosmtpd.controller import Controller as BaseController
 from aiosmtpd.smtp import SMTP as SMTPProtocol
 from contextlib import ExitStack
+from aiosmtpd.testing.helpers import (
+    ReceivingHandler,
+    get_server_context
+)
 from email.mime.text import MIMEText
 from smtplib import SMTP_SSL
 from unittest.mock import patch
@@ -31,23 +35,6 @@ class Controller(BaseController):
         return SMTPProtocol(self.handler)
 
 
-class ReceivingHandler:
-    def __init__(self):
-        self.box = []
-
-    async def handle_DATA(self, server, session, envelope):
-        self.box.append(envelope)
-        return '250 OK'
-
-
-def get_server_context():
-    tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
-    tls_context.load_cert_chain(
-        pkg_resources.resource_filename('aiosmtpd.tests.certs', 'server.crt'),
-        pkg_resources.resource_filename('aiosmtpd.tests.certs', 'server.key'))
-    return tls_context
-
-
 def get_client_context():
     context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
     context.check_hostname = False
diff --git a/aiosmtpd/tests/test_starttls.py b/aiosmtpd/tests/test_starttls.py
index b9fc261..f613574 100644
--- a/aiosmtpd/tests/test_starttls.py
+++ b/aiosmtpd/tests/test_starttls.py
@@ -1,18 +1,19 @@
 import ssl
 import unittest
-import pkg_resources
 
 from aiosmtpd.controller import Controller as BaseController
 from aiosmtpd.handlers import Sink
 from aiosmtpd.smtp import Session as Sess_, SMTP as SMTPProtocol
 from aiosmtpd.testing.helpers import (
+    ReceivingHandler,
     SUPPORTED_COMMANDS_TLS,
     assert_auth_invalid,
+    get_server_context,
 )
 from contextlib import ExitStack
 from email.mime.text import MIMEText
 from smtplib import SMTP
-from unittest.mock import patch
+from unittest.mock import Mock, patch
 
 
 ModuleResources = ExitStack()
@@ -33,30 +34,13 @@ class Controller(BaseController):
         return SMTPProtocol(self.handler)
 
 
-class ReceivingHandler:
-    def __init__(self):
-        self.box = []
-
-    async def handle_DATA(self, server, session, envelope):
-        self.box.append(envelope)
-        return '250 OK'
-
-
-def get_tls_context():
-    tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
-    tls_context.load_cert_chain(
-        pkg_resources.resource_filename('aiosmtpd.tests.certs', 'server.crt'),
-        pkg_resources.resource_filename('aiosmtpd.tests.certs', 'server.key'))
-    return tls_context
-
-
 class TLSRequiredController(Controller):
     def factory(self):
         return SMTPProtocol(
             self.handler,
             decode_data=True,
             require_starttls=True,
-            tls_context=get_tls_context())
+            tls_context=get_server_context())
 
 
 class TLSController(Controller):
@@ -65,7 +49,7 @@ class TLSController(Controller):
             self.handler,
             decode_data=True,
             require_starttls=False,
-            tls_context=get_tls_context())
+            tls_context=get_server_context())
 
 
 class RequireTLSAuthDecodingController(Controller):
@@ -74,7 +58,7 @@ class RequireTLSAuthDecodingController(Controller):
             self.handler,
             decode_data=True,
             auth_require_tls=True,
-            tls_context=get_tls_context())
+            tls_context=get_server_context())
 
 
 class HandshakeFailingHandler:
@@ -296,3 +280,36 @@ class TestRequireTLSAUTH(unittest.TestCase):
             client.ehlo('example.com')
             code, response = client.docmd('AUTH PLAIN AHRlc3QAdGVzdA==')
             assert_auth_invalid(self, code, response)
+
+
+class TestTLSContext(unittest.TestCase):
+    def test_verify_mode_nochange(self):
+        context = get_server_context()
+        for mode in (ssl.CERT_NONE, ssl.CERT_OPTIONAL):
+            context.verify_mode = mode
+            _ = SMTPProtocol(Sink(), tls_context=context)
+            self.assertEqual(mode, context.verify_mode)
+
+    @patch("logging.Logger.warning")
+    def test_certreq_warn(self, mock_warn: Mock):
+        context = get_server_context()
+        context.verify_mode = ssl.CERT_REQUIRED
+        _ = SMTPProtocol(Sink(), tls_context=context)
+        self.assertEqual(ssl.CERT_REQUIRED, context.verify_mode)
+        mock_warn.assert_called_once()
+        warn_msg = mock_warn.call_args[0][0]
+        self.assertIn("tls_context.verify_mode", warn_msg)
+        self.assertIn("might cause client connection problems", warn_msg)
+
+    @patch("logging.Logger.warning")
+    def test_nocertreq_chkhost_warn(self, mock_warn: Mock):
+        context = get_server_context()
+        # .check_hostname=True needs .verify_mode!=CERT_NONE
+        context.verify_mode = ssl.CERT_OPTIONAL
+        context.check_hostname = True
+        _ = SMTPProtocol(Sink(), tls_context=context)
+        self.assertEqual(ssl.CERT_OPTIONAL, context.verify_mode)
+        mock_warn.assert_called_once()
+        warn_msg = mock_warn.call_args[0][0]
+        self.assertIn("tls_context.check_hostname", warn_msg)
+        self.assertIn("might cause client connection problems", warn_msg)