summaryrefslogtreecommitdiff
path: root/mail/protocols/pop3/src/test/java/com
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /mail/protocols/pop3/src/test/java/com
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'mail/protocols/pop3/src/test/java/com')
-rw-r--r--mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/MockPop3Server.java420
-rw-r--r--mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3CapabilitiesTest.java19
-rw-r--r--mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt414
-rw-r--r--mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt264
-rw-r--r--mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt119
-rw-r--r--mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/SimplePop3Settings.java72
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;
+ }
+}