Skip to content

Commit dde0aab

Browse files
Regression suite: make the SpamAssassin and ClamAV tests actually run
Installing the two optional integrations - ClamAV 1.5.4 and SpamAssassin 4.0.2 - took the 27 tests that had always reported inconclusive on this machine and ran them for the first time. Two of them failed, both because of defects in the tests rather than in the server. ClamAV.TestWithVirus put the EICAR string in the plain body of a non-MIME message. ClamAV's EICAR signatures match a whole file, not a substring, and the boundary is easy to demonstrate over clamd's own INSTREAM: exact EICAR, no trailing newline 68 bytes -> Eicar-Test-Signature FOUND EICAR + CRLF 70 bytes -> Eicar-Signature FOUND EICAR + trailing text 79 bytes -> OK plain .eml, EICAR as the body 131 bytes -> OK MIME .eml, EICAR base64 attached 451 bytes -> Eicar-Test-Signature FOUND Older ClamAV extracted a plain body as a scannable part; current ClamAV does not, so the message scanned clean and was delivered - and the test failed on its own "no messages" assertion. hMailServer was never at fault: it connected, streamed the file and parsed the FOUND reply correctly, which the attachment case proves. The test now sends EICAR as a base64 attachment, which is how a virus actually arrives, and matches the signature family rather than one exact name - the name has already changed once across ClamAV releases, and the test was still asserting the old one. The EICAR string stays split across two fragments and is base64-encoded at run time so that neither form is ever a literal in the source file. On-access scanners quarantine files containing either; Windows Defender deleted two scratch files during this work for exactly that reason. SpamAssassin.TestSANotRunning had a race and a wrong expectation. StopSpamAssassin() called ServiceController.Stop() and returned immediately. Stop() only asks - it returns as soon as the request is accepted, while spamd is still up and still answering on 783. The test then sent a message straight into a live spamd and got the X-Spam-Status header it was asserting could not be there. It now waits for the service to reach Stopped and for port 783 to refuse connections, which is the condition the test actually depends on. It also asserted "There was a communication error with SpamAssassin." alongside the real error. That is error 5157, reported by SpamAssassinClient::OnReadError when a connection was established and then lost mid-read. With spamd stopped there is nothing to connect to, Connect() fails outright, and only error 5508 - "The SpamAssassin tests did not complete..." - is reported. Asserting both demanded a different failure mode from the one under test, so only 5508 remains. Verified: AntiVirus.ClamAV 5/5 with a real detect-and-delete in the log ("Virus detected: Eicar-Test-Signature", "Message will be deleted"), and AntiSpam.SpamAssassin 14/14 including the outage test. README: the setup recipe pointed at a JAM Software download that no longer exists. It now describes what actually works - Strawberry Perl plus CPAN, with spamd rebuilt via BUILD_SPAMD=yes because the Windows build skips it by default, and perl.exe copied to spamd.exe inside Perl's own bin so @inc still resolves. Also states the elevation requirement the suite has always had but never documented: it starts and stops Windows services, which a standard user cannot do, and those tests report inconclusive unelevated. No shipped code is touched by this commit; 6.2.10 as released is unaffected.
1 parent ff4826d commit dde0aab

3 files changed

Lines changed: 131 additions & 11 deletions

File tree

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -547,10 +547,11 @@ If you want to run hMailServer in debug mode in Visual Studio, add the command a
547547
Running tests
548548
-------------
549549

550-
hMailServer ships with a full regression suite (1026 NUnit tests) which exercises the server end to end over SMTP, IMAP and POP3 - including anti-spam, anti-virus, TLS, DKIM/DMARC, rules, backup and the COM API. 6.2.10 passes the complete suite with zero failures. Tests reporting *inconclusive* are the ones whose optional integration is absent from the machine running them; see the list below.
550+
hMailServer ships with a full regression suite (1026 NUnit tests) which exercises the server end to end over SMTP, IMAP and POP3 - including anti-spam, anti-virus, TLS, DKIM/DMARC, rules, backup and the COM API. 6.2.10 passes **all 1026 with zero failures and zero inconclusive**, with live SpamAssassin and ClamAV. Without those two integrations installed the 27 tests that depend on them report *inconclusive* rather than failing; the setup that makes them run is below.
551551

552552
NOTE: When running tests, your local hMailServer installation will be updated with test accounts. Existing domains and accounts are deleted. Each tests prepares the server configuration in different ways. In other words, do not run the automated tests in an environment where you need to preserve hMailServer data.
553553

554+
0. **Run the tests elevated.** The suite starts and stops Windows services - hMailServer itself, and the SpamAssassin service that the outage-handling test takes down on purpose - which a standard user cannot do. Unelevated, those tests report *inconclusive*.
554555
1. Make sure hMailServer.exe is built and can be run. The tests will launch the service.
555556
2. Open the test solution, `\hmailserver\test\hMailServer Tests.sln`
556557
3. In Visual Studio, select Test Explorer from the View-menu.
@@ -561,8 +562,29 @@ You can also navigate to the source code for a test, right-click anywhere and se
561562

562563
For 100% coverage the suite expects three optional integrations (tests degrade to *inconclusive* without them):
563564

564-
* **SpamAssassin** - the JAM Software Windows build (`https://downloads.jam-software.de/spamassassin/SpamAssassinForWindows-x64.zip`), extracted to `C:\SpamAssassin`, with `spamd.exe -i 127.0.0.1 -A 127.0.0.1 -p 783` running - ideally wrapped as a Windows service named `SpamAssassinJAM` so outage-handling tests can stop and start it.
565-
* **ClamAV** - installed to `C:\clamav` with `clamd` listening on TCP 3310 and current freshclam definitions. Let the daemon finish loading signatures before the first run.
565+
* **SpamAssassin** - the JAM Software Windows build the suite was originally written against
566+
is long gone, so build it from CPAN instead. Install Strawberry Perl, then
567+
`cpanm --notest Mail::SpamAssassin`. That skips `spamd` on Windows by default; rebuild it
568+
from the unpacked distribution with `perl Makefile.PL BUILD_SPAMD=yes BUILD_SPAMC=no` and
569+
`gmake install`, then fetch rules with `sa-update`.
570+
571+
Two details matter. The suite looks for a *process* named `spamd` before it falls back to
572+
starting the service, and `spamd` is a Perl script - so copy `perl.exe` to `spamd.exe`
573+
**inside `C:\Strawberry\perl\bin`**: Perl derives `@INC` from the location of its
574+
executable, and a copy anywhere else finds no core modules at all. Run it as
575+
`spamd.exe -T <path-to>\spamd --port 783 --listen 127.0.0.1 --round-robin --nouser-config`
576+
(`-T` is required because it is on the script's `#!` line), wrapped as a Windows service
577+
named `SpamAssassinJAM` so the outage-handling test can stop and start it.
578+
* **ClamAV** - the official Windows build, extracted to `C:\clamav` (the suite launches
579+
`C:\clamav\clamd.exe` by that exact path), with a `clamd.conf` setting `TCPSocket 3310`.
580+
Bind both `127.0.0.1` and `::1`: hMailServer connects to it as `localhost`, which resolves
581+
to either. Run `freshclam` first and let the daemon finish loading signatures before the
582+
first run - it takes a while.
583+
584+
Note the virus test sends EICAR as a base64 **attachment**, not as the message body.
585+
ClamAV's EICAR signatures match a whole file, so the string with anything around it is not
586+
detected; as an attachment ClamAV decodes it back to exactly the EICAR file and matches,
587+
which is also how a virus would really arrive.
566588
* **`AddXOriginalRcptTo=1`** in `hMailServer.INI` for the X-Original-Rcpt-To header tests.
567589

568590
The complete dev-tree provisioning recipe (directories, certificates, DB scripts, runtime files) is kept with the maintainer's internal notes; open an issue if you need it to reproduce a build.

hmailserver/test/RegressionTests/AntiSpam/SpamAssassin.cs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
// Copyright (c) 2026 Christopher Holloway / Progressive Robot Ltd
44

55
using System;
6+
using System.Net.Sockets;
67
using System.ServiceProcess;
8+
using System.Threading;
79
using hMailServer;
810
using NUnit.Framework;
911
using RegressionTests.Infrastructure;
@@ -202,7 +204,13 @@ public void TestSANotRunning()
202204

203205
Assert.IsFalse(sMessageContents.Contains("X-Spam-Status"));
204206

205-
CustomAsserts.AssertReportedError("There was a communication error with SpamAssassin.",
207+
// Only error 5508 belongs to this scenario. The "communication error with
208+
// SpamAssassin" that used to be asserted alongside it is error 5157, which
209+
// SpamAssassinClient::OnReadError reports when a connection was
210+
// established and then lost mid-read. With spamd stopped there is nothing
211+
// to connect to, so Connect() fails outright and that error never fires -
212+
// asserting it demanded a different failure mode from the one under test.
213+
CustomAsserts.AssertReportedError(
206214
"The SpamAssassin tests did not complete. Please confirm that the configuration (host name and port) is valid and that SpamAssassin is running.");
207215
}
208216

@@ -307,18 +315,58 @@ public void TestWhiteList()
307315
}
308316

309317

318+
/// <summary>
319+
/// Stops SpamAssassin and does not return until it has actually stopped
320+
/// serving. ServiceController.Stop() only asks: it returns as soon as the
321+
/// request is accepted, while spamd is still up and still answering on 783.
322+
/// The caller then sent a message straight into a live spamd and got the
323+
/// X-Spam-Status header it was asserting could not be there.
324+
///
325+
/// The service reaching Stopped is necessary but not sufficient - a wrapped
326+
/// spamd can outlive its own service by a moment - so this waits on the port
327+
/// refusing connections, which is the condition the test actually depends on.
328+
/// </summary>
310329
private static void StopSpamAssassin()
311330
{
312-
// Check if we can launch it...
313331
try
314332
{
315333
var serviceController = new ServiceController("SpamAssassinJAM");
316-
serviceController.Stop();
334+
if (serviceController.Status != ServiceControllerStatus.Stopped)
335+
{
336+
serviceController.Stop();
337+
serviceController.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(60));
338+
}
317339
}
318340
catch (Exception)
319341
{
320342
Assert.Inconclusive("Unable to stop SpamAssassin process. Is SpamAssassin installed?");
321343
}
344+
345+
for (var i = 0; i < 120; i++)
346+
{
347+
if (!IsSpamAssassinAcceptingConnections())
348+
return;
349+
350+
Thread.Sleep(250);
351+
}
352+
353+
Assert.Inconclusive("SpamAssassin is still accepting connections on port 783 after being stopped.");
354+
}
355+
356+
private static bool IsSpamAssassinAcceptingConnections()
357+
{
358+
try
359+
{
360+
using (var client = new TcpClient())
361+
{
362+
var result = client.BeginConnect("127.0.0.1", 783, null, null);
363+
return result.AsyncWaitHandle.WaitOne(500) && client.Connected;
364+
}
365+
}
366+
catch
367+
{
368+
return false;
369+
}
322370
}
323371
}
324372
}

hmailserver/test/RegressionTests/AntiVirus/ClamAV.cs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System;
2+
using System.Text;
13
using hMailServer;
24
using NUnit.Framework;
35
using RegressionTests.Infrastructure;
@@ -75,17 +77,65 @@ public void TestWithVirus()
7577
LogHandler.DeleteCurrentDefaultLog();
7678

7779
var account1 = SingletonProvider<TestSetup>.Instance.AddAccount(_domain, "test@example.test", "test");
78-
var firstPart = @"X5O!P%@AP[4\PZX54(P^)7CC)7}";
79-
var secondPart = @"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
80-
SmtpClientSimulator.StaticSend(account1.Address, account1.Address, "Mail 1",
81-
firstPart + secondPart);
80+
81+
SmtpClientSimulator.StaticSendRaw(account1.Address, account1.Address,
82+
BuildMessageWithEicarAttachment(account1.Address));
8283

8384
CustomAsserts.AssertRecipientsInDeliveryQueue(0);
8485
Pop3ClientSimulator.AssertMessageCount(account1.Address, "test", 0);
8586

8687
var defaultLog = LogHandler.ReadCurrentDefaultLog();
8788
Assert.IsTrue(defaultLog.Contains("Connecting to ClamAV"));
88-
Assert.IsTrue(defaultLog.Contains("Message will be deleted (contained virus Eicar-Signature)."));
89+
90+
// The signature name has changed over ClamAV releases - a bare EICAR file
91+
// is Eicar-Test-Signature, the same file with a trailing CRLF is
92+
// Eicar-Signature - so match the family rather than one exact name.
93+
StringAssert.IsMatch(@"Message will be deleted \(contained virus Eicar[-\w]*\)\.", defaultLog);
94+
}
95+
96+
/// <summary>
97+
/// Builds a MIME message carrying EICAR as a base64 attachment.
98+
///
99+
/// It has to be an attachment. ClamAV's EICAR signatures match a whole file,
100+
/// not a substring: the 68-byte string on its own is Eicar-Test-Signature and
101+
/// with a trailing CRLF it is Eicar-Signature, but the same string with any
102+
/// other content around it is not detected at all. This test used to put
103+
/// EICAR straight in the body of a non-MIME message, which older ClamAV
104+
/// extracted as a scannable part and current ClamAV does not - so the message
105+
/// scanned clean and was delivered. As an attachment ClamAV decodes the
106+
/// base64 back to exactly the EICAR file and matches, which is also how a
107+
/// virus would actually arrive.
108+
///
109+
/// The string is assembled from fragments and encoded at run time so that
110+
/// neither it nor its base64 form is ever a literal in this source file -
111+
/// on-access virus scanners quarantine files that contain either.
112+
/// </summary>
113+
private static string BuildMessageWithEicarAttachment(string address)
114+
{
115+
var firstPart = @"X5O!P%@AP[4\PZX54(P^)7CC)7}";
116+
var secondPart = @"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
117+
var encoded = Convert.ToBase64String(Encoding.ASCII.GetBytes(firstPart + secondPart));
118+
119+
return
120+
"From: " + address + "\r\n" +
121+
"To: " + address + "\r\n" +
122+
"Subject: Mail 1\r\n" +
123+
"MIME-Version: 1.0\r\n" +
124+
"Content-Type: multipart/mixed; boundary=\"eicar-boundary\"\r\n" +
125+
"\r\n" +
126+
"--eicar-boundary\r\n" +
127+
"Content-Type: text/plain\r\n" +
128+
"\r\n" +
129+
"Test message.\r\n" +
130+
"\r\n" +
131+
"--eicar-boundary\r\n" +
132+
"Content-Type: application/octet-stream; name=\"eicar.com\"\r\n" +
133+
"Content-Transfer-Encoding: base64\r\n" +
134+
"Content-Disposition: attachment; filename=\"eicar.com\"\r\n" +
135+
"\r\n" +
136+
encoded + "\r\n" +
137+
"\r\n" +
138+
"--eicar-boundary--\r\n";
89139
}
90140
}
91141
}

0 commit comments

Comments
 (0)