changeset 50729:b3a5af04da35 stable

tests: use simple mock smtp server instead of deprecated asyncore smtpd test-patchbomb-tls.t would fail with: .../hg/tests/dummysmtpd.py:6: DeprecationWarning: The asyncore module is deprecated and will be removed in Python 3.12. The recommended replacement is asyncio import asyncore .../hg/tests/dummysmtpd.py:8: DeprecationWarning: The smtpd module is deprecated and unmaintained and will be removed in Python 3.12. Please see aiosmtpd (https://aiosmtpd.readthedocs.io/) for the recommended replacement. import smtpd The recommended migration path to the standalone asiosmtpd would be overkill. The tests do not need a full smtp server - we can just use a very simple mock hack to preserve the existing test coverage.
author Mads Kiilerich <mads@kiilerich.com>
date Thu, 23 Mar 2023 16:45:12 +0100
parents 8823e4d411ba
children b1ac55606eb7
files tests/dummysmtpd.py tests/test-patchbomb-tls.t
diffstat 2 files changed, 96 insertions(+), 57 deletions(-) [+]
line wrap: on
line diff
--- a/tests/dummysmtpd.py	Mon Jun 26 16:45:13 2023 +0200
+++ b/tests/dummysmtpd.py	Thu Mar 23 16:45:12 2023 +0100
@@ -3,12 +3,11 @@
 """dummy SMTP server for use in tests"""
 
 
-import asyncore
 import optparse
-import smtpd
+import os
+import socket
 import ssl
 import sys
-import traceback
 
 from mercurial import (
     pycompat,
@@ -18,57 +17,97 @@
 )
 
 
+if os.environ.get('HGIPV6', '0') == '1':
+    family = socket.AF_INET6
+else:
+    family = socket.AF_INET
+
+
 def log(msg):
     sys.stdout.write(msg)
     sys.stdout.flush()
 
 
-class dummysmtpserver(smtpd.SMTPServer):
-    def __init__(self, localaddr):
-        smtpd.SMTPServer.__init__(self, localaddr, remoteaddr=None)
+def mocksmtpserversession(conn, addr):
+    conn.send(b'220 smtp.example.com ESMTP\r\n')
+
+    line = conn.recv(1024)
+    if not line.lower().startswith(b'ehlo '):
+        log('no hello: %s\n' % line)
+        return
+
+    conn.send(b'250 Hello\r\n')
+
+    line = conn.recv(1024)
+    if not line.lower().startswith(b'mail from:'):
+        log('no mail from: %s\n' % line)
+        return
+    mailfrom = line[10:].decode().rstrip()
+    if mailfrom.startswith('<') and mailfrom.endswith('>'):
+        mailfrom = mailfrom[1:-1]
+
+    conn.send(b'250 Ok\r\n')
 
-    def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
-        log(
-            '%s from=%s to=%s\n%s\n'
-            % (peer[0], mailfrom, ', '.join(rcpttos), data.decode())
-        )
+    rcpttos = []
+    while True:
+        line = conn.recv(1024)
+        if not line.lower().startswith(b'rcpt to:'):
+            break
+        rcptto = line[8:].decode().rstrip()
+        if rcptto.startswith('<') and rcptto.endswith('>'):
+            rcptto = rcptto[1:-1]
+        rcpttos.append(rcptto)
+
+        conn.send(b'250 Ok\r\n')
+
+    if not line.lower().strip() == b'data':
+        log('no rcpt to or data: %s' % line)
+
+    conn.send(b'354 Go ahead\r\n')
 
-    def handle_error(self):
-        # On Windows, a bad SSL connection sometimes generates a WSAECONNRESET.
-        # The default handler will shutdown this server, and then both the
-        # current connection and subsequent ones fail on the client side with
-        # "No connection could be made because the target machine actively
-        # refused it".  If we eat the error, then the client properly aborts in
-        # the expected way, and the server is available for subsequent requests.
-        traceback.print_exc()
+    data = b''
+    while True:
+        line = conn.recv(1024)
+        if not line:
+            log('connection closed before end of data')
+            break
+        data += line
+        if data.endswith(b'\r\n.\r\n'):
+            data = data[:-5]
+            break
+
+    conn.send(b'250 Ok\r\n')
+
+    log(
+        '%s from=%s to=%s\n%s\n'
+        % (addr[0], mailfrom, ', '.join(rcpttos), data.decode())
+    )
 
 
-class dummysmtpsecureserver(dummysmtpserver):
-    def __init__(self, localaddr, certfile):
-        dummysmtpserver.__init__(self, localaddr)
-        self._certfile = certfile
-
-    def handle_accept(self):
-        pair = self.accept()
-        if not pair:
-            return
-        conn, addr = pair
-        ui = uimod.ui.load()
+def run(host, port, certificate):
+    ui = uimod.ui.load()
+    with socket.socket(family, socket.SOCK_STREAM) as s:
+        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        s.bind((host, port))
+        # log('listening at %s:%d\n' % (host, port))
+        s.listen(1)
         try:
-            # wrap_socket() would block, but we don't care
-            conn = sslutil.wrapserversocket(conn, ui, certfile=self._certfile)
-        except ssl.SSLError as e:
-            log('%s ssl error: %s\n' % (addr[0], e))
-            conn.close()
-            return
-        smtpd.SMTPChannel(self, conn, addr)
-
-
-def run():
-    try:
-        asyncore.loop()
-    except KeyboardInterrupt:
-        pass
+            while True:
+                conn, addr = s.accept()
+                if certificate:
+                    try:
+                        conn = sslutil.wrapserversocket(
+                            conn, ui, certfile=certificate
+                        )
+                    except ssl.SSLError as e:
+                        log('%s ssl error: %s\n' % (addr[0], e))
+                        conn.close()
+                        continue
+                log("connection from %s:%s\n" % addr)
+                mocksmtpserversession(conn, addr)
+                conn.close()
+        except KeyboardInterrupt:
+            pass
 
 
 def _encodestrsonly(v):
@@ -102,19 +141,9 @@
     if (opts.tls == 'smtps') != bool(opts.certificate):
         op.error('--certificate must be specified with --tls=smtps')
 
-    addr = (opts.address, opts.port)
-
-    def init():
-        if opts.tls == 'none':
-            dummysmtpserver(addr)
-        else:
-            dummysmtpsecureserver(addr, opts.certificate)
-        log('listening at %s:%d\n' % addr)
-
     server.runservice(
         bytesvars(opts),
-        initfn=init,
-        runfn=run,
+        runfn=lambda: run(opts.address, opts.port, opts.certificate),
         runargs=[pycompat.sysexecutable, pycompat.fsencode(__file__)]
         + pycompat.sysargv[1:],
         logfile=opts.logfile,
--- a/tests/test-patchbomb-tls.t	Mon Jun 26 16:45:13 2023 +0200
+++ b/tests/test-patchbomb-tls.t	Thu Mar 23 16:45:12 2023 +0100
@@ -7,7 +7,6 @@
 
   $ "$PYTHON" "$TESTDIR/dummysmtpd.py" -p $HGPORT --pid-file a.pid --logfile log -d \
   > --tls smtps --certificate `pwd`/server.pem
-  listening at localhost:$HGPORT (?)
   $ cat a.pid >> $DAEMON_PIDS
 
 Set up repository:
@@ -47,6 +46,11 @@
   (an attempt was made to load CA certificates but none were loaded; see https://mercurial-scm.org/wiki/SecureConnections for how to configure Mercurial to avoid this error)
   (?i)abort: .*?certificate.verify.failed.* (re)
   [255]
+
+  $ cat ../log
+  * ssl error: * (glob)
+  $ : > ../log
+
 #endif
 
 #if defaultcacertsloaded
@@ -58,6 +62,10 @@
   (?i)abort: .*?certificate.verify.failed.* (re)
   [255]
 
+  $ cat ../log
+  * ssl error: * (glob)
+  $ : > ../log
+
 #endif
 
   $ DISABLECACERTS="--config devel.disableloaddefaultcerts=true"
@@ -76,7 +84,8 @@
   [150]
 
   $ cat ../log
-  * ssl error: * (glob)
+  connection from * (glob)
+  no hello: b''
   $ : > ../log
 
 With global certificates:
@@ -91,6 +100,7 @@
   sending [PATCH] a ...
 
   $ cat ../log
+  connection from * (glob)
   * from=quux to=foo, bar (glob)
   MIME-Version: 1.0
   Content-Type: text/plain; charset="us-ascii"