diff options
| author | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
|---|---|---|
| committer | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
| commit | 50ce5d5e6cd0d720ff437d35eace545816848428 (patch) | |
| tree | b59af206a36265cf081945e434900b32b0c43edd /mail/protocols/pop3/src/test/java/com | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'mail/protocols/pop3/src/test/java/com')
6 files changed, 1308 insertions, 0 deletions
diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/MockPop3Server.java b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/MockPop3Server.java new file mode 100644 index 0000000..a514282 --- /dev/null +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/MockPop3Server.java @@ -0,0 +1,420 @@ +package com.fsck.k9.mail.store.pop3; + + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.util.Deque; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +import com.fsck.k9.mail.helpers.KeyStoreProvider; +import com.jcraft.jzlib.JZlib; +import com.jcraft.jzlib.ZOutputStream; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.Okio; +import org.apache.commons.io.IOUtils; + + +public class MockPop3Server { + private static final byte[] CRLF = { '\r', '\n' }; + + + private final Deque<ImapInteraction> interactions = new ConcurrentLinkedDeque<>(); + private final CountDownLatch waitForConnectionClosed = new CountDownLatch(1); + private final CountDownLatch waitForAllExpectedCommands = new CountDownLatch(1); + private final KeyStoreProvider keyStoreProvider; + private final Logger logger; + + private MockServerThread mockServerThread; + private String host; + private int port; + + + public MockPop3Server() { + this(KeyStoreProvider.getInstance(), new DefaultLogger()); + } + + public MockPop3Server(KeyStoreProvider keyStoreProvider, Logger logger) { + this.keyStoreProvider = keyStoreProvider; + this.logger = logger; + } + + public void output(String response) { + checkServerNotRunning(); + interactions.add(new CannedResponse(response)); + } + + public void expect(String command) { + checkServerNotRunning(); + interactions.add(new ExpectedCommand(command)); + } + + public void startTls() { + checkServerNotRunning(); + interactions.add(new UpgradeToTls()); + } + + public void enableCompression() { + checkServerNotRunning(); + interactions.add(new EnableCompression()); + } + + public void closeConnection() { + checkServerNotRunning(); + interactions.add(new CloseConnection()); + } + + public void start() throws IOException { + checkServerNotRunning(); + + InetAddress localAddress = InetAddress.getByName(null); + ServerSocket serverSocket = new ServerSocket(0, 1, localAddress); + InetSocketAddress localSocketAddress = (InetSocketAddress) serverSocket.getLocalSocketAddress(); + host = localSocketAddress.getHostString(); + port = serverSocket.getLocalPort(); + + mockServerThread = new MockServerThread(serverSocket, interactions, waitForConnectionClosed, + waitForAllExpectedCommands, logger, keyStoreProvider); + mockServerThread.start(); + } + + public void shutdown() { + checkServerRunning(); + + mockServerThread.shouldStop(); + waitForMockServerThread(); + } + + private void waitForMockServerThread() { + try { + mockServerThread.join(500L); + } catch (InterruptedException ignored) { + } + } + + public String getHost() { + checkServerRunning(); + + return host; + } + + public int getPort() { + checkServerRunning(); + + return port; + } + + public void waitForInteractionToComplete() { + checkServerRunning(); + + try { + waitForAllExpectedCommands.await(1000L, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + } + } + + public void verifyInteractionCompleted() { + shutdown(); + + if (!interactions.isEmpty()) { + throw new AssertionError("Interactions left: " + interactions.size()); + } + + UnexpectedCommandException unexpectedCommandException = mockServerThread.getUnexpectedCommandException(); + if (unexpectedCommandException != null) { + throw new AssertionError(unexpectedCommandException.getMessage(), unexpectedCommandException); + } + } + + public void verifyConnectionStillOpen() { + checkServerRunning(); + + if (mockServerThread.isClientConnectionClosed()) { + throw new AssertionError("Connection closed when it shouldn't be"); + } + } + + public void verifyConnectionClosed() { + checkServerRunning(); + + try { + waitForConnectionClosed.await(300L, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + } + + if (!mockServerThread.isClientConnectionClosed()) { + throw new AssertionError("Connection open when is shouldn't be"); + } + } + + private void checkServerRunning() { + if (mockServerThread == null) { + throw new IllegalStateException("Server was never started"); + } + } + + private void checkServerNotRunning() { + if (mockServerThread != null) { + throw new IllegalStateException("Server was already started"); + } + } + + + public interface Logger { + void log(String message); + void log(String format, Object... args); + } + + private interface ImapInteraction {} + + private static class ExpectedCommand implements ImapInteraction { + private final String command; + + + public ExpectedCommand(String command) { + this.command = command; + } + + public String getCommand() { + return command; + } + } + + private static class CannedResponse implements ImapInteraction { + private final String response; + + + public CannedResponse(String response) { + this.response = response; + } + + public String getResponse() { + return response; + } + } + + private static class CloseConnection implements ImapInteraction { + } + + private static class EnableCompression implements ImapInteraction { + } + + private static class UpgradeToTls implements ImapInteraction { + } + + private static class UnexpectedCommandException extends Exception { + public UnexpectedCommandException(String expectedCommand, String receivedCommand) { + super("Expected <" + expectedCommand + ">, but received <" + receivedCommand + ">"); + } + } + + private static class MockServerThread extends Thread { + private final ServerSocket serverSocket; + private final Deque<ImapInteraction> interactions; + private final CountDownLatch waitForConnectionClosed; + private final CountDownLatch waitForAllExpectedCommands; + private final Logger logger; + private final KeyStoreProvider keyStoreProvider; + + private volatile boolean shouldStop = false; + private volatile Socket clientSocket; + + private BufferedSource input; + private BufferedSink output; + private volatile UnexpectedCommandException unexpectedCommandException; + + + public MockServerThread(ServerSocket serverSocket, Deque<ImapInteraction> interactions, + CountDownLatch waitForConnectionClosed, CountDownLatch waitForAllExpectedCommands, Logger logger, + KeyStoreProvider keyStoreProvider) { + super("MockImapServer"); + this.serverSocket = serverSocket; + this.interactions = interactions; + this.waitForConnectionClosed = waitForConnectionClosed; + this.waitForAllExpectedCommands = waitForAllExpectedCommands; + this.logger = logger; + this.keyStoreProvider = keyStoreProvider; + } + + @Override + public void run() { + String hostAddress = serverSocket.getInetAddress().getHostAddress(); + int port = serverSocket.getLocalPort(); + logger.log("Listening on %s:%d", hostAddress, port); + + Socket socket = null; + try { + socket = acceptConnectionAndCloseServerSocket(); + clientSocket = socket; + + String remoteHostAddress = socket.getInetAddress().getHostAddress(); + int remotePort = socket.getPort(); + logger.log("Accepted connection from %s:%d", remoteHostAddress, remotePort); + + input = Okio.buffer(Okio.source(socket)); + output = Okio.buffer(Okio.sink(socket)); + + while (!shouldStop && !interactions.isEmpty()) { + handleInteractions(socket); + } + + waitForAllExpectedCommands.countDown(); + + while (!shouldStop) { + readAdditionalCommands(); + } + + waitForConnectionClosed.countDown(); + } catch (UnexpectedCommandException e) { + unexpectedCommandException = e; + } catch (IOException e) { + if (!shouldStop) { + logger.log("Exception: %s", e); + } + } catch (KeyStoreException | CertificateException | UnrecoverableKeyException | + NoSuchAlgorithmException | KeyManagementException e) { + throw new RuntimeException(e); + } + + IOUtils.closeQuietly(socket); + + logger.log("Exiting"); + } + + private void handleInteractions(Socket socket) throws IOException, KeyStoreException, + NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException, KeyManagementException, + UnexpectedCommandException { + + ImapInteraction interaction = interactions.pop(); + if (interaction instanceof ExpectedCommand) { + readExpectedCommand((ExpectedCommand) interaction); + } else if (interaction instanceof CannedResponse) { + writeCannedResponse((CannedResponse) interaction); + } else if (interaction instanceof CloseConnection) { + clientSocket.close(); + } else if (interaction instanceof EnableCompression) { + enableCompression(socket); + } else if (interaction instanceof UpgradeToTls) { + upgradeToTls(socket); + } + } + + private void readExpectedCommand(ExpectedCommand expectedCommand) throws IOException, + UnexpectedCommandException { + + String command = input.readUtf8Line(); + if (command == null) { + throw new EOFException(); + } + + logger.log("C: %s", command); + + String expected = expectedCommand.getCommand(); + if (!command.equals(expected)) { + logger.log("EXPECTED: %s", expected); + throw new UnexpectedCommandException(expected, command); + } + } + + private void writeCannedResponse(CannedResponse cannedResponse) throws IOException { + String response = cannedResponse.getResponse(); + logger.log("S: %s", response); + + output.writeUtf8(response); + output.write(CRLF); + output.flush(); + } + + private void enableCompression(Socket socket) throws IOException { + InputStream inputStream = new InflaterInputStream(socket.getInputStream(), new Inflater(true)); + input = Okio.buffer(Okio.source(inputStream)); + + ZOutputStream outputStream = new ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true); + outputStream.setFlushMode(JZlib.Z_PARTIAL_FLUSH); + output = Okio.buffer(Okio.sink(outputStream)); + } + + private void upgradeToTls(Socket socket) throws KeyStoreException, IOException, NoSuchAlgorithmException, + CertificateException, UnrecoverableKeyException, KeyManagementException { + + KeyStore keyStore = keyStoreProvider.getKeyStore(); + + String defaultAlgorithm = KeyManagerFactory.getDefaultAlgorithm(); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(defaultAlgorithm); + keyManagerFactory.init(keyStore, keyStoreProvider.getPassword()); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), null, null); + SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + + SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket( + socket, socket.getInetAddress().getHostAddress(), socket.getPort(), true); + sslSocket.setUseClientMode(false); + sslSocket.startHandshake(); + + input = Okio.buffer(Okio.source(sslSocket.getInputStream())); + output = Okio.buffer(Okio.sink(sslSocket.getOutputStream())); + } + + private void readAdditionalCommands() throws IOException { + String command = input.readUtf8Line(); + if (command == null) { + throw new EOFException(); + } + + logger.log("Received additional command: %s", command); + } + + private Socket acceptConnectionAndCloseServerSocket() throws IOException { + Socket socket = serverSocket.accept(); + serverSocket.close(); + + return socket; + } + + public void shouldStop() { + shouldStop = true; + + IOUtils.closeQuietly(clientSocket); + } + + public boolean isClientConnectionClosed() { + return clientSocket.isClosed(); + } + + public UnexpectedCommandException getUnexpectedCommandException() { + return unexpectedCommandException; + } + } + + private static class DefaultLogger implements Logger { + @Override + public void log(String message) { + System.out.println("MockPop3Server: " + message); + } + + @Override + public void log(String format, Object... args) { + log(String.format(format, args)); + } + } +} diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3CapabilitiesTest.java b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3CapabilitiesTest.java new file mode 100644 index 0000000..478c027 --- /dev/null +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3CapabilitiesTest.java @@ -0,0 +1,19 @@ +package com.fsck.k9.mail.store.pop3; + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class Pop3CapabilitiesTest { + + @Test + public void toString_producesReadableOutput() { + String result = new Pop3Capabilities().toString(); + + assertEquals( + "CRAM-MD5 false, PLAIN false, STLS false, TOP false, UIDL false, EXTERNAL false", + result); + } +} diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt new file mode 100644 index 0000000..57af3ec --- /dev/null +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt @@ -0,0 +1,414 @@ +package com.fsck.k9.mail.store.pop3 + +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isFailure +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.AuthType.CRAM_MD5 +import com.fsck.k9.mail.AuthType.EXTERNAL +import com.fsck.k9.mail.AuthType.LOGIN +import com.fsck.k9.mail.AuthType.PLAIN +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.CertificateValidationException +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ConnectionSecurity.NONE +import com.fsck.k9.mail.ConnectionSecurity.SSL_TLS_REQUIRED +import com.fsck.k9.mail.ConnectionSecurity.STARTTLS_REQUIRED +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.helpers.TestTrustedSocketFactory +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import java.io.IOException +import java.security.NoSuchAlgorithmException +import java.security.cert.CertificateException +import javax.net.ssl.SSLException +import okio.ByteString.Companion.encodeUtf8 +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions + +class Pop3ConnectionTest { + private val socketFactory = TestTrustedSocketFactory.newInstance() + + @Test(expected = CertificateValidationException::class) + fun `when TrustedSocketFactory throws SSLCertificateException, open() should throw CertificateValidationException`() { + val server = startTlsServer() + val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED) + val mockSocketFactory = mock<TrustedSocketFactory> { + on { createSocket(null, settings.host, settings.port, null) } doThrow SSLException(CertificateException()) + } + + createAndOpenPop3Connection(settings, mockSocketFactory) + } + + @Test(expected = MessagingException::class) + fun `when TrustedSocketFactory throws CertificateException, open() should throw MessagingException`() { + val server = startTlsServer() + val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED) + val mockSocketFactory = mock<TrustedSocketFactory> { + on { createSocket(null, settings.host, settings.port, null) } doThrow SSLException("") + } + + createAndOpenPop3Connection(settings, mockSocketFactory) + } + + @Test(expected = MessagingException::class) + fun `when TrustedSocketFactory throws NoSuchAlgorithmException, open() should throw MessagingException`() { + val server = startTlsServer() + val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED) + val mockSocketFactory = mock<TrustedSocketFactory> { + on { createSocket(null, settings.host, settings.port, null) } doThrow NoSuchAlgorithmException() + } + + createAndOpenPop3Connection(settings, mockSocketFactory) + } + + @Test(expected = MessagingException::class) + fun `when TrustedSocketFactory throws IOException, open() should throw MessagingException`() { + val server = startTlsServer() + val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED) + val mockSocketFactory = mock<TrustedSocketFactory> { + on { createSocket(null, settings.host, settings.port, null) } doThrow IOException() + } + + createAndOpenPop3Connection(settings, mockSocketFactory) + } + + @Test(expected = CertificateValidationException::class) + fun `open() with STLS capability unavailable should throw CertificateValidationException`() { + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN") + } + val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED) + + createAndOpenPop3Connection(settings) + } + + @Test(expected = Pop3ErrorResponse::class) + fun `open() with error response to STLS command should throw`() { + val server = startServer { + setupServerWithStartTlsAvailable() + expect("STLS") + output("-ERR Unavailable") + } + val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED) + + createAndOpenPop3Connection(settings) + } + + @Test + fun `open() with STLS error response should not call createSocket() to upgrade to TLS`() { + val server = startServer { + setupServerWithStartTlsAvailable() + expect("STLS") + output("-ERR Unavailable") + } + val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED) + val mockSocketFactory = mock<TrustedSocketFactory>() + + try { + createAndOpenPop3Connection(settings, mockSocketFactory) + } catch (ignored: Exception) { + } + + verifyNoInteractions(mockSocketFactory) + } + + @Test(expected = MessagingException::class) + fun `open() with StartTLS and TrustedSocketFactory throwing should throw`() { + val server = startServer { + setupServerWithStartTlsAvailable() + expect("STLS") + output("+OK Begin TLS negotiation") + } + val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED) + val mockSocketFactory = mock<TrustedSocketFactory> { + on { createSocket(any(), eq(settings.host), eq(settings.port), eq(null)) } doThrow IOException() + } + + createAndOpenPop3Connection(settings, mockSocketFactory) + } + + @Test + fun `open() with AUTH PLAIN`() { + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL") + expect("AUTH PLAIN") + output("+OK") + expect(AUTH_PLAIN_ARGUMENT) + output("+OK") + } + val settings = server.createSettings(authType = PLAIN) + + createAndOpenPop3Connection(settings) + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with authentication error should throw`() { + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL") + expect("AUTH PLAIN") + output("+OK") + expect(AUTH_PLAIN_ARGUMENT) + output("-ERR") + } + val settings = server.createSettings(authType = PLAIN) + + assertThat { + createAndOpenPop3Connection(settings) + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + + server.verifyInteractionCompleted() + } + + @Test + fun `open() with AuthType_PLAIN and no SASL PLAIN capability should use USER and PASS commands`() { + val server = startServer { + setupServerWithAuthenticationMethods("CRAM-MD5 EXTERNAL") + expect("USER user") + output("+OK") + expect("PASS password") + output("+OK") + } + val settings = server.createSettings(authType = PLAIN) + + createAndOpenPop3Connection(settings) + + server.verifyInteractionCompleted() + } + + @Test + fun `open() with authentication failure during fallback to USER and PASS commands should throw`() { + val server = startServer { + setupServerWithAuthenticationMethods("CRAM-MD5 EXTERNAL") + expect("USER user") + output("+OK") + expect("PASS password") + output("-ERR") + } + val settings = server.createSettings(authType = PLAIN) + + assertThat { + createAndOpenPop3Connection(settings) + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + + server.verifyInteractionCompleted() + } + + @Test + fun `open() with CRAM-MD5 authentication`() { + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL") + expect("AUTH CRAM-MD5") + output("+ abcd") + expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg==") + output("+OK") + } + val settings = server.createSettings(authType = CRAM_MD5) + + createAndOpenPop3Connection(settings) + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with authentication failure when using CRAM-MD5 should throw`() { + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL") + expect("AUTH CRAM-MD5") + output("+ abcd") + expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg==") + output("-ERR") + } + val settings = server.createSettings(authType = CRAM_MD5) + + assertThat { + createAndOpenPop3Connection(settings) + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + + server.verifyInteractionCompleted() + } + + @Test + fun `open() with CRAM-MD5 configured but missing capability should use APOP`() { + val server = startServer { + output("+OK abc<a>abcd") + expect("CAPA") + output("+OK Listing of supported mechanisms follows") + output("SASL PLAIN EXTERNAL") + output(".") + expect("APOP user c8e8c560e385faaa6367d4145572b8ea") + output("+OK") + } + val settings = server.createSettings(authType = CRAM_MD5) + + createAndOpenPop3Connection(settings) + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with authentication failure when using APOP should throw`() { + val server = startServer { + output("+OK abc<a>abcd") + expect("CAPA") + output("+OK Listing of supported mechanisms follows") + output("SASL PLAIN EXTERNAL") + output(".") + expect("APOP user c8e8c560e385faaa6367d4145572b8ea") + output("-ERR") + } + val settings = server.createSettings(authType = CRAM_MD5) + + assertThat { + createAndOpenPop3Connection(settings) + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + + server.verifyInteractionCompleted() + } + + @Test + fun `open() with AUTH EXTERNAL`() { + val server = startServer { + setupServerWithAuthenticationMethods("CRAM-MD5 EXTERNAL") + expect("AUTH EXTERNAL dXNlcg==") + output("+OK") + } + val settings = server.createSettings(authType = EXTERNAL) + + createAndOpenPop3Connection(settings) + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with AuthType_EXTERNAL configured but missing capability should throw`() { + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5") + } + val settings = server.createSettings(authType = EXTERNAL) + + assertThat { + createAndOpenPop3Connection(settings) + }.isFailure() + .isInstanceOf(CertificateValidationException::class.java) + .prop(CertificateValidationException::getReason) + .isEqualTo(CertificateValidationException.Reason.MissingCapability) + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with authentication failure when using AUTH EXTERNAL should throw`() { + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL") + expect("AUTH EXTERNAL dXNlcg==") + output("-ERR Invalid certificate") + } + val settings = server.createSettings(authType = EXTERNAL) + + assertThat { + createAndOpenPop3Connection(settings) + }.isFailure() + .isInstanceOf(CertificateValidationException::class.java) + .hasMessage("POP3 client certificate authentication failed: -ERR Invalid certificate") + + server.verifyInteractionCompleted() + } + + @Test + fun `open() with StartTLS and AUTH PLAIN`() { + val server = startServer { + setupServerWithStartTlsAvailable() + expect("STLS") + output("+OK Begin TLS negotiation") + startTls() + expect("CAPA") + output("+OK Listing of supported mechanisms follows") + output("SASL PLAIN") + output(".") + expect("AUTH PLAIN") + output("+OK") + expect(AUTH_PLAIN_ARGUMENT) + output("+OK") + } + val settings = server.createSettings(authType = PLAIN, connectionSecurity = STARTTLS_REQUIRED) + + createAndOpenPop3Connection(settings) + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + private fun createAndOpenPop3Connection( + settings: Pop3Settings, + trustedSocketFactory: TrustedSocketFactory = socketFactory + ) { + val connection = Pop3Connection(settings, trustedSocketFactory) + connection.open() + } + + private fun MockPop3Server.setupServerWithAuthenticationMethods(authenticationMethods: String) { + output("+OK POP3 server greeting") + expect("CAPA") + output("+OK Listing of supported mechanisms follows") + output("SASL $authenticationMethods") + output(".") + } + + private fun MockPop3Server.setupServerWithStartTlsAvailable() { + output("+OK POP3 server greeting") + expect("CAPA") + output("+OK Listing of supported mechanisms follows") + output("STLS") + output("SASL PLAIN") + output(".") + } + + private fun startTlsServer(): MockPop3Server { + // MockPop3Server doesn't actually support implicit TLS. However, all tests using this method will encounter + // an exception before sending the first command to the server. + return startServer { } + } + + private fun MockPop3Server.createSettings( + authType: AuthType = LOGIN, + connectionSecurity: ConnectionSecurity = NONE + ): Pop3Settings { + return SimplePop3Settings().apply { + username = USERNAME + password = PASSWORD + this.authType = authType + host = this@createSettings.host + port = this@createSettings.port + this.connectionSecurity = connectionSecurity + } + } + + private fun startServer(block: MockPop3Server.() -> Unit): MockPop3Server { + return MockPop3Server().apply(block).also { it.start() } + } + + companion object { + private const val USERNAME = "user" + private const val PASSWORD = "password" + + private val AUTH_PLAIN_ARGUMENT = "\u0000$USERNAME\u0000$PASSWORD".encodeUtf8().base64() + } +} diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt new file mode 100644 index 0000000..bcdf634 --- /dev/null +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt @@ -0,0 +1,264 @@ +package com.fsck.k9.mail.store.pop3 + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isSameAs +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.Body +import com.fsck.k9.mail.FetchProfile +import com.fsck.k9.mail.MessageRetrievalListener +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.crlf +import com.fsck.k9.mail.internet.BinaryTempFileBody +import com.fsck.k9.mail.store.pop3.Pop3Commands.STAT_COMMAND +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.stubbing + +class Pop3FolderTest { + private val connection = mock<Pop3Connection> { + on { executeSimpleCommand(STAT_COMMAND) } doReturn "+OK $MESSAGE_COUNT 0" + on { isOpen } doReturn true + } + private val store = mock<Pop3Store> { + on { createConnection() } doReturn connection + } + private val messageRetrievalListener = mock<MessageRetrievalListener<Pop3Message>>() + private val folder = Pop3Folder(store, Pop3Folder.INBOX) + + @Before + fun setUp() { + BinaryTempFileBody.setTempDirectory(File(System.getProperty("java.io.tmpdir"))) + } + + @Test(expected = MessagingException::class) + fun `open() without Inbox folder should throw`() { + val folder = Pop3Folder(store, "TestFolder") + + folder.open() + } + + @Test + fun `open() without Inbox folder should not call Pop3Store_createConnection()`() { + val folder = Pop3Folder(store, "TestFolder") + + try { + folder.open() + } catch (ignored: Exception) { + } + + verify(store, never()).createConnection() + } + + @Test(expected = MessagingException::class) + fun `open() with exception when creating a connection should throw`() { + stubbing(store) { + on { createConnection() } doThrow MessagingException("Test") + } + + folder.open() + } + + @Test(expected = AuthenticationFailedException::class) + fun `open() with failed authentication should throw`() { + stubbing(connection) { + on { open() } doThrow AuthenticationFailedException("Test") + } + + folder.open() + } + + @Test + fun `open() should set message count from STAT response`() { + folder.open() + + assertThat(folder.messageCount).isEqualTo(MESSAGE_COUNT) + } + + @Test(expected = MessagingException::class) + fun `open() with STAT command failing should throw`() { + stubbing(connection) { + on { executeSimpleCommand(STAT_COMMAND) } doThrow MessagingException("Test") + } + + folder.open() + } + + @Test + fun `open() should open connection`() { + folder.open() + + verify(store, times(1)).createConnection() + verify(connection).open() + } + + @Test + fun `open() with connection already open should not create another connection`() { + folder.open() + + folder.open() + + verify(store, times(1)).createConnection() + } + + @Test + fun `close() with closed folder should not throw`() { + folder.close() + } + + @Test + fun `close() with open folder should not throw`() { + folder.open() + + folder.close() + } + + @Test + fun `close() with open folder should send QUIT command`() { + folder.open() + + folder.close() + + verify(connection).executeSimpleCommand(Pop3Commands.QUIT_COMMAND) + } + + @Test + fun `close() with exception when sending QUIT command should not throw`() { + stubbing(connection) { + on { executeSimpleCommand(Pop3Commands.QUIT_COMMAND) } doThrow MessagingException("Test") + } + folder.open() + + folder.close() + } + + @Test + fun `close() with open folder should close connection`() { + folder.open() + + folder.close() + + verify(connection).close() + } + + @Test + fun `getMessages() should return list of messages on server`() { + stubbing(connection) { + on { readLine() } doReturn "1 $MESSAGE_SERVER_ID" doReturn "." + } + folder.open() + + val result = folder.getMessages(1, 1, messageRetrievalListener) + + assertThat(result).hasSize(1) + } + + @Test(expected = MessagingException::class) + fun `getMessages() with invalid set should throw`() { + folder.open() + + folder.getMessages(2, 1, messageRetrievalListener) + } + + @Test(expected = MessagingException::class) + fun `getMessages() with IOException when reading line should throw`() { + stubbing(connection) { + on { readLine() } doThrow IOException("Test") + } + folder.open() + + folder.getMessages(1, 1, messageRetrievalListener) + } + + @Test + fun `getMessage() with previously fetched message should return message`() { + folder.open() + val messageList = setupMessageFromServer() + + val message = folder.getMessage(MESSAGE_SERVER_ID) + + assertThat(message).isSameAs(messageList.first()) + } + + @Test + fun `getMessage() without previously fetched message should return new message`() { + folder.open() + + val message = folder.getMessage(MESSAGE_SERVER_ID) + + assertThat(message).isNotNull() + } + + @Test + fun `fetch() with ENVELOPE profile should set size of message`() { + folder.open() + val messageList = setupMessageFromServer() + val fetchProfile = FetchProfile() + fetchProfile.add(FetchProfile.Item.ENVELOPE) + stubbing(connection) { + on { readLine() } doReturn "1 100" doReturn "." + } + + folder.fetch(messageList, fetchProfile, messageRetrievalListener, MAX_DOWNLOAD_SIZE) + + assertThat(messageList.first().size).isEqualTo(100) + } + + @Test + fun `fetch() with BODY profile should set content of message`() { + val messageInputStream = + """ + From: <adam@example.org> + To: <eva@example.org> + Subject: Testmail + MIME-Version: 1.0 + Content-type: text/plain + Content-Transfer-Encoding: 7bit + + this is some test text. + """.trimIndent().crlf().byteInputStream() + folder.open() + val messageList = setupMessageFromServer() + val fetchProfile = FetchProfile() + fetchProfile.add(FetchProfile.Item.BODY) + stubbing(connection) { + on { readLine() } doReturn("1 100") doReturn(".") + on { inputStream } doReturn messageInputStream + } + + folder.fetch(messageList, fetchProfile, messageRetrievalListener, MAX_DOWNLOAD_SIZE) + + assertThat(messageList.first().body.writeToString()).isEqualTo("this is some test text.") + } + + private fun setupMessageFromServer(): List<Pop3Message> { + stubbing(connection) { + on { readLine() } doReturn "1 $MESSAGE_SERVER_ID" doReturn "." + } + + return folder.getMessages(1, 1, messageRetrievalListener) + } + + private fun Body.writeToString(): String { + return ByteArrayOutputStream().also { outputStream -> + writeTo(outputStream) + }.toByteArray().decodeToString() + } + + companion object { + private const val MESSAGE_COUNT = 10 + private const val MESSAGE_SERVER_ID = "abcd" + private const val MAX_DOWNLOAD_SIZE = -1 + } +} diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt new file mode 100644 index 0000000..c1d5d01 --- /dev/null +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt @@ -0,0 +1,119 @@ +package com.fsck.k9.mail.store.pop3 + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isSameAs +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.Socket +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.stubbing + +class Pop3StoreTest { + private val trustedSocketFactory = mock<TrustedSocketFactory>() + private val store: Pop3Store = Pop3Store(createServerSettings(), trustedSocketFactory) + + @Test + fun `getFolder() should return same instance every time`() { + val folderOne = store.getFolder("TestFolder") + val folderTwo = store.getFolder("TestFolder") + + assertThat(folderTwo).isSameAs(folderOne) + } + + @Test + fun `getFolder() should return folder with correct server ID`() { + val folder = store.getFolder("TestFolder") + + assertThat(folder.serverId).isEqualTo("TestFolder") + } + + @Test(expected = MessagingException::class) + fun `checkSettings() with TrustedSocketFactory throwing should throw MessagingException`() { + stubbing(trustedSocketFactory) { + on { createSocket(null, HOST, 12345, null) } doThrow IOException() + } + + store.checkSettings() + } + + @Test(expected = MessagingException::class) + fun `checkSettings() with UIDL command not supported should throw MessagingException`() { + setupSocketWithResponse( + INITIAL_RESPONSE + + CAPA_RESPONSE + + AUTH_PLAIN_AUTHENTICATED_RESPONSE + + STAT_RESPONSE + + UIDL_UNSUPPORTED_RESPONSE + ) + + store.checkSettings() + } + + @Test + fun `checkSettings() with UIDL supported`() { + setupSocketWithResponse( + INITIAL_RESPONSE + + CAPA_RESPONSE + + AUTH_PLAIN_AUTHENTICATED_RESPONSE + + STAT_RESPONSE + + UIDL_SUPPORTED_RESPONSE + ) + + store.checkSettings() + } + + private fun createServerSettings(): ServerSettings { + return ServerSettings( + type = "pop3", + host = HOST, + port = 12345, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null + ) + } + + private fun setupSocketWithResponse(response: String): ByteArrayOutputStream { + val outputStream = ByteArrayOutputStream() + + val socket = mock<Socket> { + on { isConnected } doReturn true + on { isClosed } doReturn false + on { getOutputStream() } doReturn outputStream + on { getInputStream() } doReturn response.byteInputStream() + } + + stubbing(trustedSocketFactory) { + on { createSocket(null, HOST, 12345, null) } doReturn socket + } + + return outputStream + } + + companion object { + private const val HOST = "127.0.0.1" + private const val INITIAL_RESPONSE = "+OK POP3 server greeting\r\n" + + private const val CAPA_RESPONSE = "+OK Listing of supported mechanisms follows\r\n" + + "SASL PLAIN CRAM-MD5 EXTERNAL\r\n" + + ".\r\n" + + private const val AUTH_PLAIN_AUTHENTICATED_RESPONSE = "+OK\r\n" + "+OK\r\n" + + private const val STAT_RESPONSE = "+OK 20 0\r\n" + + private const val UIDL_UNSUPPORTED_RESPONSE = "-ERR UIDL unsupported\r\n" + private const val UIDL_SUPPORTED_RESPONSE = "+OK UIDL supported\r\n" + } +} diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/SimplePop3Settings.java b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/SimplePop3Settings.java new file mode 100644 index 0000000..37edc46 --- /dev/null +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/SimplePop3Settings.java @@ -0,0 +1,72 @@ +package com.fsck.k9.mail.store.pop3; + +import com.fsck.k9.mail.AuthType; +import com.fsck.k9.mail.ConnectionSecurity; + +class SimplePop3Settings implements Pop3Settings { + private String host; + private int port; + private ConnectionSecurity connectionSecurity = ConnectionSecurity.NONE; + private AuthType authType; + private String username; + private String password; + + @Override + public String getHost() { + return host; + } + + @Override + public int getPort() { + return port; + } + + @Override + public ConnectionSecurity getConnectionSecurity() { + return connectionSecurity; + } + + @Override + public AuthType getAuthType() { + return authType; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getClientCertificateAlias() { + return null; + } + + void setHost(String host) { + this.host = host; + } + + void setPort(int port) { + this.port = port; + } + + void setConnectionSecurity(ConnectionSecurity connectionSecurity) { + this.connectionSecurity = connectionSecurity; + } + + void setAuthType(AuthType authType) { + this.authType = authType; + } + + void setUsername(String username) { + this.username = username; + } + + void setPassword(String password) { + this.password = password; + } +} |
