xref: /plugin/smtp/_test/IntegrationTest.php (revision 30b0aec1b43572b8f5091314125a38d68397b736)
1<?php
2
3namespace dokuwiki\plugin\smtp\test;
4
5use DokuWikiTest;
6use dokuwiki\HTTP\DokuHTTPClient;
7use Mailer;
8
9/**
10 * Full integration test for the smtp plugin
11 *
12 * This sends a real mail through DokuWiki's Mailer (which the plugin intercepts and
13 * delivers over SMTP) to a running Mailpit server and then verifies through Mailpit's
14 * HTTP API that the message was delivered correctly.
15 *
16 * The Mailpit server is configured through environment variables:
17 *   MAILPIT_HOST       hostname of the Mailpit server (enables the test)
18 *   MAILPIT_SMTP_PORT  the SMTP port to deliver to         (default: 1025)
19 *   MAILPIT_HTTP_PORT  the HTTP API/web interface port     (default: 8025)
20 *
21 * When MAILPIT_HOST is not set the test is skipped, so it does not break the regular
22 * test suite which runs without the Mailpit service. When it is set the server must be
23 * reachable, otherwise the test fails.
24 *
25 * The STARTTLS test (smtp_allow_insecure) uses the same server, so that Mailpit must be
26 * started with --smtp-tls-cert/--smtp-tls-key to offer STARTTLS (a self-signed
27 * certificate is exactly what this test wants to exercise); otherwise it fails.
28 *
29 * @group plugin_smtp
30 * @group plugins
31 */
32class IntegrationTest extends DokuWikiTest
33{
34    protected $pluginsEnabled = ['smtp'];
35
36    /** @var string hostname of the Mailpit server */
37    protected $host;
38
39    /** @var int SMTP port to deliver to */
40    protected $smtpPort;
41
42    /** @var string base URL of the Mailpit HTTP API, without trailing slash */
43    protected $apiBase;
44
45    /** @inheritdoc */
46    public function setUp(): void
47    {
48        parent::setUp();
49
50        // the test only runs when a Mailpit server is configured via MAILPIT_HOST,
51        // otherwise it is skipped (eg. regular CI/local runs without Mailpit)
52        $this->host = getenv('MAILPIT_HOST');
53        if ($this->host === false || $this->host === '') {
54            $this->markTestSkipped('MAILPIT_HOST is not set, skipping the Mailpit integration test');
55        }
56
57        $this->smtpPort = (int)(getenv('MAILPIT_SMTP_PORT') ?: 1025);
58        $httpPort = (int)(getenv('MAILPIT_HTTP_PORT') ?: 8025);
59        $this->apiBase = 'http://' . $this->host . ':' . $httpPort;
60
61        // a Mailpit server was requested, so it must actually be reachable
62        $sock = @fsockopen($this->host, $this->smtpPort, $errno, $errstr, 2);
63        if (!$sock) {
64            $this->fail("No Mailpit SMTP server reachable at $this->host:$this->smtpPort ($errstr)");
65        }
66        fclose($sock);
67
68        // point the plugin at our Mailpit server
69        global $conf;
70        $conf['plugin']['smtp']['smtp_host'] = $this->host;
71        $conf['plugin']['smtp']['smtp_port'] = $this->smtpPort;
72        $conf['plugin']['smtp']['smtp_ssl'] = '';
73        $conf['plugin']['smtp']['auth_user'] = '';
74        $conf['plugin']['smtp']['auth_pass'] = '';
75        $conf['plugin']['smtp']['localdomain'] = 'test.localhost';
76        $conf['plugin']['smtp']['debug'] = 0;
77
78        // give DokuWiki's Mailer a sane default sender
79        $conf['mailfrom'] = 'wiki@example.com';
80    }
81
82    /**
83     * A mail sent through DokuWiki's Mailer must be delivered to Mailpit over SMTP.
84     *
85     * To and Cc come from the message headers, while the Bcc recipient is delivered
86     * through the SMTP envelope even though the plugin strips the Bcc header from the
87     * message body.
88     */
89    public function testSendMail(): void
90    {
91        $subject = 'SMTP plugin integration test ' . uniqid('', true);
92        $bodyText = 'Hello from the DokuWiki smtp plugin integration test.';
93
94        $mailer = new Mailer();
95        $mailer->from('Wiki Admin <wiki@example.com>');
96        $mailer->to('Jane Doe <jane@example.com>');
97        $mailer->cc('carol@example.com');
98        $mailer->bcc('secret@example.com');
99        $mailer->subject($subject);
100        $mailer->setBody($bodyText);
101
102        $ok = $mailer->send();
103        $this->assertTrue($ok, 'Mailer::send() should report success when talking to Mailpit');
104
105        // the message should now be available through the Mailpit API
106        $message = $this->findMessageBySubject($subject);
107        $this->assertNotNull($message, 'The sent message should show up in Mailpit');
108
109        // sender and header recipients
110        $this->assertEquals('wiki@example.com', $message['From']['Address']);
111        $this->assertEquals(['jane@example.com'], $this->addresses($message['To']));
112        $this->assertEquals(['carol@example.com'], $this->addresses($message['Cc']));
113        $this->assertEquals(['secret@example.com'], $this->addresses($message['Bcc']));
114
115        // the body must have arrived intact
116        $full = $this->apiGet('/api/v1/message/' . rawurlencode($message['ID']));
117        $this->assertStringContainsString($bodyText, $full['Text']);
118    }
119
120    /**
121     * The smtp_allow_insecure option must control whether an untrusted STARTTLS
122     * certificate is accepted (issue #32).
123     *
124     * Against a Mailpit offering STARTTLS with a self-signed certificate, sending must
125     * fail while certificate verification is on (smtp_allow_insecure off) and succeed
126     * once verification is disabled (smtp_allow_insecure on).
127     */
128    public function testSendMailTlsAllowsInsecureCert(): void
129    {
130        // the same server and HTTP API as the plain test, just talked to over STARTTLS
131        global $conf;
132        $conf['plugin']['smtp']['smtp_ssl'] = 'tls';
133
134        // with certificate verification enabled the self-signed cert is rejected
135        $conf['plugin']['smtp']['smtp_allow_insecure'] = 0;
136        $rejectSubject = 'SMTP plugin TLS reject test ' . uniqid('', true);
137        $this->assertFalse(
138            $this->sendProbe($rejectSubject),
139            'Sending over STARTTLS with an untrusted certificate must fail when smtp_allow_insecure is off'
140        );
141        $this->assertNull(
142            $this->findMessageBySubject($rejectSubject),
143            'A message rejected during STARTTLS must not be delivered'
144        );
145
146        // disabling verification accepts the self-signed cert and delivers the mail
147        $conf['plugin']['smtp']['smtp_allow_insecure'] = 1;
148        $acceptSubject = 'SMTP plugin TLS insecure test ' . uniqid('', true);
149        $this->assertTrue(
150            $this->sendProbe($acceptSubject),
151            'Sending over STARTTLS with a self-signed certificate must succeed when smtp_allow_insecure is on'
152        );
153        $this->assertNotNull(
154            $this->findMessageBySubject($acceptSubject),
155            'The mail sent over insecure STARTTLS should show up in Mailpit'
156        );
157    }
158
159    /**
160     * Send a minimal mail through DokuWiki's Mailer and report whether it succeeded
161     *
162     * An untrusted certificate makes the mailer library emit a PHP SSL warning deep in
163     * the call stack; it is swallowed here so the test can observe the boolean send
164     * result rather than abort on the warning.
165     *
166     * @param string $subject unique subject for the probe mail
167     * @return bool whether the mail was sent successfully
168     */
169    protected function sendProbe(string $subject): bool
170    {
171        $mailer = new Mailer();
172        $mailer->from('Wiki Admin <wiki@example.com>');
173        $mailer->to('Jane Doe <jane@example.com>');
174        $mailer->subject($subject);
175        $mailer->setBody('STARTTLS integration probe.');
176
177        set_error_handler(static fn() => true);
178        try {
179            return (bool)$mailer->send();
180        } finally {
181            restore_error_handler();
182        }
183    }
184
185    /**
186     * Reduce a Mailpit address list to a plain list of email addresses
187     *
188     * @param array $list list of {Name, Address} entries as returned by Mailpit
189     * @return string[]
190     */
191    protected function addresses(array $list): array
192    {
193        return array_map(static fn($addr) => $addr['Address'], $list);
194    }
195
196    /**
197     * Find a message in Mailpit whose subject contains the given needle
198     *
199     * DokuWiki prefixes the subject with the wiki title (eg. "[My Wiki] ..."), so we
200     * match on a unique substring rather than the full subject. Mailpit stores the
201     * message before acknowledging the SMTP DATA command, so it is available as soon
202     * as Mailer::send() returns - no polling needed.
203     *
204     * @param string $needle a unique substring of the subject
205     * @return array|null the message stub from the listing or null if not found
206     */
207    protected function findMessageBySubject($needle)
208    {
209        $list = $this->apiGet('/api/v1/messages');
210        foreach ($list['messages'] as $msg) {
211            if (strpos($msg['Subject'], $needle) !== false) return $msg;
212        }
213        return null;
214    }
215
216    /**
217     * Perform a GET request against the Mailpit API and return the decoded JSON
218     *
219     * @param string $path API path including the leading slash
220     * @return array
221     */
222    protected function apiGet($path)
223    {
224        $client = new DokuHTTPClient();
225        $body = $client->get($this->apiBase . $path);
226        $this->assertNotFalse($body, 'Mailpit API request to ' . $path . ' failed: ' . $client->error);
227        return json_decode($body, true);
228    }
229}
230