xref: /plugin/smtp/_test/IntegrationTest.php (revision 30b0aec1b43572b8f5091314125a38d68397b736)
18f9f7168SAndreas Gohr<?php
28f9f7168SAndreas Gohr
38f9f7168SAndreas Gohrnamespace dokuwiki\plugin\smtp\test;
48f9f7168SAndreas Gohr
58f9f7168SAndreas Gohruse DokuWikiTest;
68f9f7168SAndreas Gohruse dokuwiki\HTTP\DokuHTTPClient;
78f9f7168SAndreas Gohruse Mailer;
88f9f7168SAndreas Gohr
98f9f7168SAndreas Gohr/**
108f9f7168SAndreas Gohr * Full integration test for the smtp plugin
118f9f7168SAndreas Gohr *
128f9f7168SAndreas Gohr * This sends a real mail through DokuWiki's Mailer (which the plugin intercepts and
138f9f7168SAndreas Gohr * delivers over SMTP) to a running Mailpit server and then verifies through Mailpit's
148f9f7168SAndreas Gohr * HTTP API that the message was delivered correctly.
158f9f7168SAndreas Gohr *
168f9f7168SAndreas Gohr * The Mailpit server is configured through environment variables:
178f9f7168SAndreas Gohr *   MAILPIT_HOST       hostname of the Mailpit server (enables the test)
188f9f7168SAndreas Gohr *   MAILPIT_SMTP_PORT  the SMTP port to deliver to         (default: 1025)
198f9f7168SAndreas Gohr *   MAILPIT_HTTP_PORT  the HTTP API/web interface port     (default: 8025)
208f9f7168SAndreas Gohr *
218f9f7168SAndreas Gohr * When MAILPIT_HOST is not set the test is skipped, so it does not break the regular
228f9f7168SAndreas Gohr * test suite which runs without the Mailpit service. When it is set the server must be
238f9f7168SAndreas Gohr * reachable, otherwise the test fails.
248f9f7168SAndreas Gohr *
25*30b0aec1SAndreas Gohr * The STARTTLS test (smtp_allow_insecure) uses the same server, so that Mailpit must be
26*30b0aec1SAndreas Gohr * started with --smtp-tls-cert/--smtp-tls-key to offer STARTTLS (a self-signed
27*30b0aec1SAndreas Gohr * certificate is exactly what this test wants to exercise); otherwise it fails.
28*30b0aec1SAndreas Gohr *
298f9f7168SAndreas Gohr * @group plugin_smtp
308f9f7168SAndreas Gohr * @group plugins
318f9f7168SAndreas Gohr */
328f9f7168SAndreas Gohrclass IntegrationTest extends DokuWikiTest
338f9f7168SAndreas Gohr{
348f9f7168SAndreas Gohr    protected $pluginsEnabled = ['smtp'];
358f9f7168SAndreas Gohr
368f9f7168SAndreas Gohr    /** @var string hostname of the Mailpit server */
378f9f7168SAndreas Gohr    protected $host;
388f9f7168SAndreas Gohr
398f9f7168SAndreas Gohr    /** @var int SMTP port to deliver to */
408f9f7168SAndreas Gohr    protected $smtpPort;
418f9f7168SAndreas Gohr
428f9f7168SAndreas Gohr    /** @var string base URL of the Mailpit HTTP API, without trailing slash */
438f9f7168SAndreas Gohr    protected $apiBase;
448f9f7168SAndreas Gohr
458f9f7168SAndreas Gohr    /** @inheritdoc */
468f9f7168SAndreas Gohr    public function setUp(): void
478f9f7168SAndreas Gohr    {
488f9f7168SAndreas Gohr        parent::setUp();
498f9f7168SAndreas Gohr
508f9f7168SAndreas Gohr        // the test only runs when a Mailpit server is configured via MAILPIT_HOST,
518f9f7168SAndreas Gohr        // otherwise it is skipped (eg. regular CI/local runs without Mailpit)
528f9f7168SAndreas Gohr        $this->host = getenv('MAILPIT_HOST');
538f9f7168SAndreas Gohr        if ($this->host === false || $this->host === '') {
548f9f7168SAndreas Gohr            $this->markTestSkipped('MAILPIT_HOST is not set, skipping the Mailpit integration test');
558f9f7168SAndreas Gohr        }
568f9f7168SAndreas Gohr
578f9f7168SAndreas Gohr        $this->smtpPort = (int)(getenv('MAILPIT_SMTP_PORT') ?: 1025);
588f9f7168SAndreas Gohr        $httpPort = (int)(getenv('MAILPIT_HTTP_PORT') ?: 8025);
598f9f7168SAndreas Gohr        $this->apiBase = 'http://' . $this->host . ':' . $httpPort;
608f9f7168SAndreas Gohr
618f9f7168SAndreas Gohr        // a Mailpit server was requested, so it must actually be reachable
628f9f7168SAndreas Gohr        $sock = @fsockopen($this->host, $this->smtpPort, $errno, $errstr, 2);
638f9f7168SAndreas Gohr        if (!$sock) {
648f9f7168SAndreas Gohr            $this->fail("No Mailpit SMTP server reachable at $this->host:$this->smtpPort ($errstr)");
658f9f7168SAndreas Gohr        }
668f9f7168SAndreas Gohr        fclose($sock);
678f9f7168SAndreas Gohr
688f9f7168SAndreas Gohr        // point the plugin at our Mailpit server
698f9f7168SAndreas Gohr        global $conf;
708f9f7168SAndreas Gohr        $conf['plugin']['smtp']['smtp_host'] = $this->host;
718f9f7168SAndreas Gohr        $conf['plugin']['smtp']['smtp_port'] = $this->smtpPort;
728f9f7168SAndreas Gohr        $conf['plugin']['smtp']['smtp_ssl'] = '';
738f9f7168SAndreas Gohr        $conf['plugin']['smtp']['auth_user'] = '';
748f9f7168SAndreas Gohr        $conf['plugin']['smtp']['auth_pass'] = '';
758f9f7168SAndreas Gohr        $conf['plugin']['smtp']['localdomain'] = 'test.localhost';
768f9f7168SAndreas Gohr        $conf['plugin']['smtp']['debug'] = 0;
778f9f7168SAndreas Gohr
788f9f7168SAndreas Gohr        // give DokuWiki's Mailer a sane default sender
798f9f7168SAndreas Gohr        $conf['mailfrom'] = 'wiki@example.com';
808f9f7168SAndreas Gohr    }
818f9f7168SAndreas Gohr
828f9f7168SAndreas Gohr    /**
838f9f7168SAndreas Gohr     * A mail sent through DokuWiki's Mailer must be delivered to Mailpit over SMTP.
848f9f7168SAndreas Gohr     *
858f9f7168SAndreas Gohr     * To and Cc come from the message headers, while the Bcc recipient is delivered
868f9f7168SAndreas Gohr     * through the SMTP envelope even though the plugin strips the Bcc header from the
878f9f7168SAndreas Gohr     * message body.
888f9f7168SAndreas Gohr     */
898f9f7168SAndreas Gohr    public function testSendMail(): void
908f9f7168SAndreas Gohr    {
918f9f7168SAndreas Gohr        $subject = 'SMTP plugin integration test ' . uniqid('', true);
928f9f7168SAndreas Gohr        $bodyText = 'Hello from the DokuWiki smtp plugin integration test.';
938f9f7168SAndreas Gohr
948f9f7168SAndreas Gohr        $mailer = new Mailer();
958f9f7168SAndreas Gohr        $mailer->from('Wiki Admin <wiki@example.com>');
968f9f7168SAndreas Gohr        $mailer->to('Jane Doe <jane@example.com>');
978f9f7168SAndreas Gohr        $mailer->cc('carol@example.com');
988f9f7168SAndreas Gohr        $mailer->bcc('secret@example.com');
998f9f7168SAndreas Gohr        $mailer->subject($subject);
1008f9f7168SAndreas Gohr        $mailer->setBody($bodyText);
1018f9f7168SAndreas Gohr
1028f9f7168SAndreas Gohr        $ok = $mailer->send();
1038f9f7168SAndreas Gohr        $this->assertTrue($ok, 'Mailer::send() should report success when talking to Mailpit');
1048f9f7168SAndreas Gohr
1058f9f7168SAndreas Gohr        // the message should now be available through the Mailpit API
1068f9f7168SAndreas Gohr        $message = $this->findMessageBySubject($subject);
1078f9f7168SAndreas Gohr        $this->assertNotNull($message, 'The sent message should show up in Mailpit');
1088f9f7168SAndreas Gohr
1098f9f7168SAndreas Gohr        // sender and header recipients
1108f9f7168SAndreas Gohr        $this->assertEquals('wiki@example.com', $message['From']['Address']);
1118f9f7168SAndreas Gohr        $this->assertEquals(['jane@example.com'], $this->addresses($message['To']));
1128f9f7168SAndreas Gohr        $this->assertEquals(['carol@example.com'], $this->addresses($message['Cc']));
1138f9f7168SAndreas Gohr        $this->assertEquals(['secret@example.com'], $this->addresses($message['Bcc']));
1148f9f7168SAndreas Gohr
1158f9f7168SAndreas Gohr        // the body must have arrived intact
1168f9f7168SAndreas Gohr        $full = $this->apiGet('/api/v1/message/' . rawurlencode($message['ID']));
1178f9f7168SAndreas Gohr        $this->assertStringContainsString($bodyText, $full['Text']);
1188f9f7168SAndreas Gohr    }
1198f9f7168SAndreas Gohr
1208f9f7168SAndreas Gohr    /**
121*30b0aec1SAndreas Gohr     * The smtp_allow_insecure option must control whether an untrusted STARTTLS
122*30b0aec1SAndreas Gohr     * certificate is accepted (issue #32).
123*30b0aec1SAndreas Gohr     *
124*30b0aec1SAndreas Gohr     * Against a Mailpit offering STARTTLS with a self-signed certificate, sending must
125*30b0aec1SAndreas Gohr     * fail while certificate verification is on (smtp_allow_insecure off) and succeed
126*30b0aec1SAndreas Gohr     * once verification is disabled (smtp_allow_insecure on).
127*30b0aec1SAndreas Gohr     */
128*30b0aec1SAndreas Gohr    public function testSendMailTlsAllowsInsecureCert(): void
129*30b0aec1SAndreas Gohr    {
130*30b0aec1SAndreas Gohr        // the same server and HTTP API as the plain test, just talked to over STARTTLS
131*30b0aec1SAndreas Gohr        global $conf;
132*30b0aec1SAndreas Gohr        $conf['plugin']['smtp']['smtp_ssl'] = 'tls';
133*30b0aec1SAndreas Gohr
134*30b0aec1SAndreas Gohr        // with certificate verification enabled the self-signed cert is rejected
135*30b0aec1SAndreas Gohr        $conf['plugin']['smtp']['smtp_allow_insecure'] = 0;
136*30b0aec1SAndreas Gohr        $rejectSubject = 'SMTP plugin TLS reject test ' . uniqid('', true);
137*30b0aec1SAndreas Gohr        $this->assertFalse(
138*30b0aec1SAndreas Gohr            $this->sendProbe($rejectSubject),
139*30b0aec1SAndreas Gohr            'Sending over STARTTLS with an untrusted certificate must fail when smtp_allow_insecure is off'
140*30b0aec1SAndreas Gohr        );
141*30b0aec1SAndreas Gohr        $this->assertNull(
142*30b0aec1SAndreas Gohr            $this->findMessageBySubject($rejectSubject),
143*30b0aec1SAndreas Gohr            'A message rejected during STARTTLS must not be delivered'
144*30b0aec1SAndreas Gohr        );
145*30b0aec1SAndreas Gohr
146*30b0aec1SAndreas Gohr        // disabling verification accepts the self-signed cert and delivers the mail
147*30b0aec1SAndreas Gohr        $conf['plugin']['smtp']['smtp_allow_insecure'] = 1;
148*30b0aec1SAndreas Gohr        $acceptSubject = 'SMTP plugin TLS insecure test ' . uniqid('', true);
149*30b0aec1SAndreas Gohr        $this->assertTrue(
150*30b0aec1SAndreas Gohr            $this->sendProbe($acceptSubject),
151*30b0aec1SAndreas Gohr            'Sending over STARTTLS with a self-signed certificate must succeed when smtp_allow_insecure is on'
152*30b0aec1SAndreas Gohr        );
153*30b0aec1SAndreas Gohr        $this->assertNotNull(
154*30b0aec1SAndreas Gohr            $this->findMessageBySubject($acceptSubject),
155*30b0aec1SAndreas Gohr            'The mail sent over insecure STARTTLS should show up in Mailpit'
156*30b0aec1SAndreas Gohr        );
157*30b0aec1SAndreas Gohr    }
158*30b0aec1SAndreas Gohr
159*30b0aec1SAndreas Gohr    /**
160*30b0aec1SAndreas Gohr     * Send a minimal mail through DokuWiki's Mailer and report whether it succeeded
161*30b0aec1SAndreas Gohr     *
162*30b0aec1SAndreas Gohr     * An untrusted certificate makes the mailer library emit a PHP SSL warning deep in
163*30b0aec1SAndreas Gohr     * the call stack; it is swallowed here so the test can observe the boolean send
164*30b0aec1SAndreas Gohr     * result rather than abort on the warning.
165*30b0aec1SAndreas Gohr     *
166*30b0aec1SAndreas Gohr     * @param string $subject unique subject for the probe mail
167*30b0aec1SAndreas Gohr     * @return bool whether the mail was sent successfully
168*30b0aec1SAndreas Gohr     */
169*30b0aec1SAndreas Gohr    protected function sendProbe(string $subject): bool
170*30b0aec1SAndreas Gohr    {
171*30b0aec1SAndreas Gohr        $mailer = new Mailer();
172*30b0aec1SAndreas Gohr        $mailer->from('Wiki Admin <wiki@example.com>');
173*30b0aec1SAndreas Gohr        $mailer->to('Jane Doe <jane@example.com>');
174*30b0aec1SAndreas Gohr        $mailer->subject($subject);
175*30b0aec1SAndreas Gohr        $mailer->setBody('STARTTLS integration probe.');
176*30b0aec1SAndreas Gohr
177*30b0aec1SAndreas Gohr        set_error_handler(static fn() => true);
178*30b0aec1SAndreas Gohr        try {
179*30b0aec1SAndreas Gohr            return (bool)$mailer->send();
180*30b0aec1SAndreas Gohr        } finally {
181*30b0aec1SAndreas Gohr            restore_error_handler();
182*30b0aec1SAndreas Gohr        }
183*30b0aec1SAndreas Gohr    }
184*30b0aec1SAndreas Gohr
185*30b0aec1SAndreas Gohr    /**
1868f9f7168SAndreas Gohr     * Reduce a Mailpit address list to a plain list of email addresses
1878f9f7168SAndreas Gohr     *
1888f9f7168SAndreas Gohr     * @param array $list list of {Name, Address} entries as returned by Mailpit
1898f9f7168SAndreas Gohr     * @return string[]
1908f9f7168SAndreas Gohr     */
1918f9f7168SAndreas Gohr    protected function addresses(array $list): array
1928f9f7168SAndreas Gohr    {
1938f9f7168SAndreas Gohr        return array_map(static fn($addr) => $addr['Address'], $list);
1948f9f7168SAndreas Gohr    }
1958f9f7168SAndreas Gohr
1968f9f7168SAndreas Gohr    /**
1978f9f7168SAndreas Gohr     * Find a message in Mailpit whose subject contains the given needle
1988f9f7168SAndreas Gohr     *
1998f9f7168SAndreas Gohr     * DokuWiki prefixes the subject with the wiki title (eg. "[My Wiki] ..."), so we
2008f9f7168SAndreas Gohr     * match on a unique substring rather than the full subject. Mailpit stores the
2018f9f7168SAndreas Gohr     * message before acknowledging the SMTP DATA command, so it is available as soon
2028f9f7168SAndreas Gohr     * as Mailer::send() returns - no polling needed.
2038f9f7168SAndreas Gohr     *
2048f9f7168SAndreas Gohr     * @param string $needle a unique substring of the subject
2058f9f7168SAndreas Gohr     * @return array|null the message stub from the listing or null if not found
2068f9f7168SAndreas Gohr     */
2078f9f7168SAndreas Gohr    protected function findMessageBySubject($needle)
2088f9f7168SAndreas Gohr    {
2098f9f7168SAndreas Gohr        $list = $this->apiGet('/api/v1/messages');
2108f9f7168SAndreas Gohr        foreach ($list['messages'] as $msg) {
2118f9f7168SAndreas Gohr            if (strpos($msg['Subject'], $needle) !== false) return $msg;
2128f9f7168SAndreas Gohr        }
2138f9f7168SAndreas Gohr        return null;
2148f9f7168SAndreas Gohr    }
2158f9f7168SAndreas Gohr
2168f9f7168SAndreas Gohr    /**
2178f9f7168SAndreas Gohr     * Perform a GET request against the Mailpit API and return the decoded JSON
2188f9f7168SAndreas Gohr     *
2198f9f7168SAndreas Gohr     * @param string $path API path including the leading slash
2208f9f7168SAndreas Gohr     * @return array
2218f9f7168SAndreas Gohr     */
2228f9f7168SAndreas Gohr    protected function apiGet($path)
2238f9f7168SAndreas Gohr    {
2248f9f7168SAndreas Gohr        $client = new DokuHTTPClient();
2258f9f7168SAndreas Gohr        $body = $client->get($this->apiBase . $path);
2268f9f7168SAndreas Gohr        $this->assertNotFalse($body, 'Mailpit API request to ' . $path . ' failed: ' . $client->error);
2278f9f7168SAndreas Gohr        return json_decode($body, true);
2288f9f7168SAndreas Gohr    }
2298f9f7168SAndreas Gohr}
230