Repository: aio-libs/aiosmtpd

Issue:
aiosmtpd test suite fails on Python 3.10
While trying to build aiosmtpd 1.4.2 on Fedora for Python 3.10 (as part of [upgrading to Python 3.10 for Fedora Linux 35](https://fedoraproject.org/wiki/Changes/Python3.10)), aiosmtpd failed to build because the test suite fails:

```
=========================== short test summary info ============================
FAILED aiosmtpd/tests/test_server.py::TestUnixSocketController::test_server_creation_ssl
FAILED aiosmtpd/tests/test_smtp.py::TestAuthMechanisms::test_byclient[login-False]
FAILED aiosmtpd/tests/test_smtp.py::TestAuthArgs::test_warn_authreqnotls - as...
ERROR aiosmtpd/tests/test_smtps.py::TestSMTPS::test_smtps - ssl.SSLError: Can...
ERROR aiosmtpd/tests/test_starttls.py::TestNoTLS::test_disabled_tls - OSError...
ERROR aiosmtpd/tests/test_starttls.py::TestStartTLS::test_help_starttls - OSE...
ERROR aiosmtpd/tests/test_starttls.py::TestStartTLS::test_starttls_arg - OSEr...
ERROR aiosmtpd/tests/test_starttls.py::TestStartTLS::test_starttls - OSError:...
ERROR aiosmtpd/tests/test_starttls.py::TestStartTLS::test_starttls_quit - OSE...
ERROR aiosmtpd/tests/test_starttls.py::TestStartTLS::test_failed_handshake - ...
ERROR aiosmtpd/tests/test_starttls.py::TestStartTLS::test_tls_handshake_stopcontroller
ERROR aiosmtpd/tests/test_starttls.py::TestStartTLS::test_tls_bad_syntax - OS...
ERROR aiosmtpd/tests/test_starttls.py::TestStartTLS::test_help_after_starttls
ERROR aiosmtpd/tests/test_starttls.py::TestStartTLS::test_helo_starttls - OSE...
ERROR aiosmtpd/tests/test_starttls.py::TestTLSEnding::test_eof_received - OSE...
ERROR aiosmtpd/tests/test_starttls.py::TestTLSEnding::test_tls_handshake_failing
ERROR aiosmtpd/tests/test_starttls.py::TestTLSForgetsSessionData::test_forget_ehlo
ERROR aiosmtpd/tests/test_starttls.py::TestTLSForgetsSessionData::test_forget_mail
ERROR aiosmtpd/tests/test_starttls.py::TestTLSForgetsSessionData::test_forget_rcpt
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLS::test_helo_fails - OSEr...
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLS::test_help_fails - OSEr...
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLS::test_ehlo - OSError: [...
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLS::test_mail_fails - OSEr...
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLS::test_rcpt_fails - OSEr...
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLS::test_vrfy_fails - OSEr...
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLS::test_data_fails - OSEr...
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLS::test_noop_okay - OSErr...
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLS::test_quit_okay - OSErr...
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLSAUTH::test_auth_notls - ...
ERROR aiosmtpd/tests/test_starttls.py::TestRequireTLSAUTH::test_auth_tls - OS...
= 3 failed, 521 passed, 1 skipped, 113 warnings, 27 errors in 186.09s (0:03:06) =
```

Most of it is the test suite returning `OSError: [Errno 98] error while attempting to bind on address ('127.0.0.1', 8025): address already in use`, but there are other errors too...

Full build log: [python-aiosmtpd-1.4.2-3.fc35-build.log.txt](https://github.com/aio-libs/aiosmtpd/files/6885253/python-aiosmtpd-1.4.2-3.fc35-build.log.txt)

Reference Koji build task: https://koji.fedoraproject.org/koji/taskinfo?taskID=72467725

cc: @abompard, @hroncok

Comments:

Patch:
diff --git a/aiosmtpd/controller.py b/aiosmtpd/controller.py
index 075d74b..8a336d9 100644
--- a/aiosmtpd/controller.py
+++ b/aiosmtpd/controller.py
@@ -79,6 +79,24 @@ def get_localhost() -> Literal["::1", "127.0.0.1"]:
         raise
 
 
+def _server_to_client_ssl_ctx(server_ctx: ssl.SSLContext) -> ssl.SSLContext:
+    """
+    Given an SSLContext object with TLS_SERVER_PROTOCOL return a client
+    context that can connect to the server.
+    """
+    client_ctx = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
+    client_ctx.options = server_ctx.options
+    client_ctx.check_hostname = False
+    # We do not verify the ssl cert for the server here simply because this
+    # is a local connection to poke at the server for it to do its lazy
+    # initialization sequence. The only purpose of this client context
+    # is to make a connection to the *local* server created using the same
+    # code. That is also the reason why we disable cert verification below
+    # and the flake8 check for the same.
+    client_ctx.verify_mode = ssl.CERT_NONE    # noqa: DUO122
+    return client_ctx
+
+
 class _FakeServer(asyncio.StreamReaderProtocol):
     """
     Returned by _factory_invoker() in lieu of an SMTP instance in case
@@ -425,7 +443,8 @@ class InetMixin(BaseController, metaclass=ABCMeta):
         with ExitStack() as stk:
             s = stk.enter_context(create_connection((hostname, self.port), 1.0))
             if self.ssl_context:
-                s = stk.enter_context(self.ssl_context.wrap_socket(s))
+                client_ctx = _server_to_client_ssl_ctx(self.ssl_context)
+                s = stk.enter_context(client_ctx.wrap_socket(s))
             s.recv(1024)
 
 
@@ -467,7 +486,8 @@ class UnixSocketMixin(BaseController, metaclass=ABCMeta):  # pragma: no-unixsock
             s: makesock = stk.enter_context(makesock(AF_UNIX, SOCK_STREAM))
             s.connect(self.unix_socket)
             if self.ssl_context:
-                s = stk.enter_context(self.ssl_context.wrap_socket(s))
+                client_ctx = _server_to_client_ssl_ctx(self.ssl_context)
+                s = stk.enter_context(client_ctx.wrap_socket(s))
             s.recv(1024)
 
 
diff --git a/setup.cfg b/setup.cfg
index 9f16d3a..63ae3a8 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -25,6 +25,7 @@ classifiers =
     Programming Language :: Python :: 3.7
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
+    Programming Language :: Python :: 3.10
     Programming Language :: Python :: Implementation :: CPython
     Programming Language :: Python :: Implementation :: PyPy
     Topic :: Communications :: Email :: Mail Transport Agents


Test patch:
diff --git a/.github/workflows/unit-testing-and-coverage.yml b/.github/workflows/unit-testing-and-coverage.yml
index e17be25..8d4d2ef 100644
--- a/.github/workflows/unit-testing-and-coverage.yml
+++ b/.github/workflows/unit-testing-and-coverage.yml
@@ -89,7 +89,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ "macos-10.15", "ubuntu-18.04", "ubuntu-20.04", "windows-latest" ]
-        python-version: [ "3.6", "3.7", "3.8", "3.9", "pypy3.6" ]
+        python-version: [ "3.6", "3.7", "3.8", "3.9", "3.10", "pypy3.6" ]
     runs-on: ${{ matrix.os }}
     timeout-minutes: 15  # Slowest so far is pypy3 on MacOS, taking almost 7m
     steps:
diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py
index 0449310..bda6d3b 100644
--- a/aiosmtpd/tests/test_server.py
+++ b/aiosmtpd/tests/test_server.py
@@ -28,6 +28,7 @@ from aiosmtpd.controller import (
     UnixSocketUnthreadedController,
     _FakeServer,
     get_localhost,
+    _server_to_client_ssl_ctx,
 )
 from aiosmtpd.handlers import Sink
 from aiosmtpd.smtp import SMTP as Server
@@ -101,7 +102,10 @@ def safe_socket_dir() -> Generator[Path, None, None]:
 def assert_smtp_socket(controller: UnixSocketMixin) -> bool:
     assert Path(controller.unix_socket).exists()
     sockfile = controller.unix_socket
-    ssl_context = controller.ssl_context
+    if controller.ssl_context:
+        ssl_context = _server_to_client_ssl_ctx(controller.ssl_context)
+    else:
+        ssl_context = None
     with ExitStack() as stk:
         sock: socket.socket = stk.enter_context(
             socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
diff --git a/aiosmtpd/tests/test_smtp.py b/aiosmtpd/tests/test_smtp.py
index 69a0871..0907d37 100644
--- a/aiosmtpd/tests/test_smtp.py
+++ b/aiosmtpd/tests/test_smtp.py
@@ -1946,12 +1946,16 @@ class TestAuthArgs:
     def test_warn_authreqnotls(self, caplog):
         with pytest.warns(UserWarning) as record:
             _ = Server(Sink(), auth_required=True, auth_require_tls=False)
-        assert len(record) == 1
-        assert (
-            record[0].message.args[0]
-            == "Requiring AUTH while not requiring TLS can lead to "
-            "security vulnerabilities!"
-        )
+        for warning in record:
+            if warning.message.args and (
+                warning.message.args[0]
+                == "Requiring AUTH while not requiring TLS can lead to "
+                "security vulnerabilities!"
+            ):
+                break
+            else:
+                pytest.xfail("Did not raise expected warning")
+
         assert caplog.record_tuples[0] == (
             "mail.log",
             logging.WARNING,