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/imap/src/test/java | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'mail/protocols/imap/src/test/java')
30 files changed, 5990 insertions, 0 deletions
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/AlertResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/AlertResponseTest.java new file mode 100644 index 0000000..3617dea --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/AlertResponseTest.java @@ -0,0 +1,66 @@ +package com.fsck.k9.mail.store.imap; + + +import org.junit.Test; + +import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + + +public class AlertResponseTest { + + @Test + public void getAlertText_withProperAlertResponse() throws Exception { + ImapResponse imapResponse = createImapResponse("x NO [ALERT] Please don't do that"); + + String result = AlertResponse.getAlertText(imapResponse); + + assertEquals("Please don't do that", result); + } + + @Test + public void getAlertText_withoutResponseCodeText_shouldReturnNull() throws Exception { + ImapResponse imapResponse = createImapResponse("x NO"); + + String result = AlertResponse.getAlertText(imapResponse); + + assertNull(result); + } + + @Test + public void getAlertText_withoutAlertText_shouldReturnNull() throws Exception { + ImapResponse imapResponse = createImapResponse("x NO [ALERT]"); + + String result = AlertResponse.getAlertText(imapResponse); + + assertNull(result); + } + + @Test + public void getAlertText_withoutResponseCodeTextList_shouldReturnNull() throws Exception { + ImapResponse imapResponse = createImapResponse("x NO ALERT ALARM!"); + + String result = AlertResponse.getAlertText(imapResponse); + + assertNull(result); + } + + @Test + public void getAlertText_withResponseCodeTextContainingTooManyItems_shouldReturnNull() throws Exception { + ImapResponse imapResponse = createImapResponse("x NO [ALERT SOMETHING] ALARM!"); + + String result = AlertResponse.getAlertText(imapResponse); + + assertNull(result); + } + + @Test + public void getAlertText_withWrongResponseCodeText_shouldReturnNull() throws Exception { + ImapResponse imapResponse = createImapResponse("x NO [ALARM] ALERT!"); + + String result = AlertResponse.getAlertText(imapResponse); + + assertNull(result); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/CapabilityResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/CapabilityResponseTest.java new file mode 100644 index 0000000..e82670c --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/CapabilityResponseTest.java @@ -0,0 +1,124 @@ +package com.fsck.k9.mail.store.imap; + + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; +import org.mockito.internal.util.collections.Sets; + +import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + + +public class CapabilityResponseTest { + + @Test + public void parse_withProperResponseContainingCapabilityCode() throws Exception { + CapabilityResponse result = parse("* OK [CAPABILITY IMAP4rev1 IDLE] Welcome"); + + assertNotNull(result); + assertEquals(Sets.newSet("IMAP4REV1", "IDLE"), result.getCapabilities()); + } + + @Test + public void parse_withTaggedResponse_shouldReturnNull() throws Exception { + CapabilityResponse result = parse("1 OK"); + + assertNull(result); + } + + @Test + public void parse_withoutOkResponse_shouldReturnNull() throws Exception { + CapabilityResponse result = parse("* BAD Go Away"); + + assertNull(result); + } + + @Test + public void parse_withOkResponseWithoutList_shouldReturnNull() throws Exception { + CapabilityResponse result = parse("* OK Welcome"); + + assertNull(result); + } + + @Test + public void parse_withProperCapabilityResponse() throws Exception { + ImapList list = createImapResponse("* CAPABILITY IMAP4rev1 STARTTLS AUTH=GSSAPI XPIG-LATIN"); + + CapabilityResponse result = CapabilityResponse.parse(list); + + assertNotNull(result); + assertEquals(Sets.newSet("IMAP4REV1", "STARTTLS", "AUTH=GSSAPI", "XPIG-LATIN"), result.getCapabilities()); + } + + @Test + public void parse_withListInCapabilityResponse_shouldReturnNull() throws Exception { + ImapList list = createImapResponse("* CAPABILITY IMAP4rev1 []"); + + CapabilityResponse result = CapabilityResponse.parse(list); + + assertNull(result); + } + + @Test + public void parse_withoutCapabilityResponse_shouldReturnNull() throws Exception { + ImapList list = createImapResponse("* EXISTS 1"); + + CapabilityResponse result = CapabilityResponse.parse(list); + + assertNull(result); + } + + @Test + public void parse_withEmptyResponseList_shouldReturnNull() throws Exception { + List<ImapResponse> responses = Collections.emptyList(); + + CapabilityResponse result = CapabilityResponse.parse(responses); + + assertNull(result); + } + + @Test + public void parse_withoutCapabilityResponseInResponseList_shouldReturnNull() throws Exception { + List<ImapResponse> responses = Collections.singletonList(createImapResponse("* EXISTS 42")); + + CapabilityResponse result = CapabilityResponse.parse(responses); + + assertNull(result); + } + + @Test + public void parse_withSingleCapabilityResponseInResponseList() throws Exception { + ImapResponse response = createImapResponse("* CAPABILITY IMAP4rev1 LOGINDISABLED STARTTLS"); + List<ImapResponse> responses = Collections.singletonList(response); + + CapabilityResponse result = CapabilityResponse.parse(responses); + + assertNotNull(result); + assertEquals(Sets.newSet("IMAP4REV1", "STARTTLS", "LOGINDISABLED"), result.getCapabilities()); + } + + @Test + public void parse_withCapabilityResponseInResponseList() throws Exception { + ImapResponse responseOne = createImapResponse("* EXPUNGE 4"); + ImapResponse responseTwo = createImapResponse("* CAPABILITY IMAP4rev1 IDLE"); + List<ImapResponse> responses = Arrays.asList(responseOne, responseTwo); + + CapabilityResponse result = CapabilityResponse.parse(responses); + + assertNotNull(result); + assertEquals(Sets.newSet("IMAP4REV1", "IDLE"), result.getCapabilities()); + } + + private CapabilityResponse parse(String responseText) throws IOException { + ImapResponse response = createImapResponse(responseText); + List<ImapResponse> responses = Collections.singletonList(response); + + return CapabilityResponse.parse(responses); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt new file mode 100644 index 0000000..3ca1a4e --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt @@ -0,0 +1,29 @@ +package com.fsck.k9.mail.store.imap + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class FolderNameCodecTest { + private var folderNameCode = FolderNameCodec() + + @Test + fun `encode() with ASCII argument should return input`() { + assertThat(folderNameCode.encode("ASCII")).isEqualTo("ASCII") + } + + @Test + fun `encode() with non-ASCII argument should return encoded string`() { + assertThat(folderNameCode.encode("über")).isEqualTo("&APw-ber") + } + + @Test + fun `decode() with encoded argument should return decoded string`() { + assertThat(folderNameCode.decode("&ANw-bergr&APYA3w-entr&AOQ-ger")).isEqualTo("Übergrößenträger") + } + + @Test(expected = CharacterCodingException::class) + fun `decode() with invalid encoded argument should throw`() { + folderNameCode.decode("&12-foo") + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt new file mode 100644 index 0000000..f225cac --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt @@ -0,0 +1,57 @@ +package com.fsck.k9.mail.store.imap + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import org.junit.Test + +class IdGrouperTest { + @Test + fun `groupIds() with single contiguous group`() { + val ids = setOf(1L, 2L, 3L) + + val groupedIds = IdGrouper.groupIds(ids) + + assertThat(groupedIds.ids).isEmpty() + assertThat(groupedIds.idGroups.mapToString()).containsExactly("1:3") + } + + @Test + fun `groupIds() without contiguous group`() { + val ids = setOf(23L, 42L, 2L, 5L) + + val groupedIds = IdGrouper.groupIds(ids) + + assertThat(groupedIds.ids).isEqualTo(ids) + assertThat(groupedIds.idGroups).isEmpty() + } + + @Test + fun `groupIds() with multiple contiguous groups`() { + val ids = setOf(1L, 3L, 4L, 5L, 6L, 10L, 12L, 13L, 14L, 23L) + + val groupedIds = IdGrouper.groupIds(ids) + + assertThat(groupedIds.ids).containsExactlyInAnyOrder(1L, 10L, 23L) + assertThat(groupedIds.idGroups.mapToString()).containsExactly("3:6", "12:14") + } + + @Test + fun `groupIds() with single ID`() { + val ids = setOf(23L) + + val groupedIds = IdGrouper.groupIds(ids) + + assertThat(groupedIds.ids).containsExactlyInAnyOrder(23L) + assertThat(groupedIds.idGroups).isEmpty() + } + + @Test(expected = IllegalArgumentException::class) + fun `groupIds() with empty set should throw`() { + IdGrouper.groupIds(emptySet()) + } +} + +private fun <T> List<T>.mapToString() = map { it.toString() } diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.kt new file mode 100644 index 0000000..f1da44f --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.kt @@ -0,0 +1,79 @@ +package com.fsck.k9.mail.store.imap + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isLessThanOrEqualTo +import java.util.TreeSet +import org.junit.Test + +class ImapCommandSplitterTest { + + @Test + fun splitCommand_withManyNonContiguousIds_shouldSplitCommand() { + val ids = ImapResponseHelper.createNonContiguousIdSet(10000, 10500, 2) + val groupedIds = GroupedIds(ids, emptyList()) + + val commands = ImapCommandSplitter.splitCommand(COMMAND_PREFIX, COMMAND_SUFFIX, groupedIds, 980) + + assertThat(commands.size).isEqualTo(2) + assertCommandLengthLimit(commands, 980) + verifyCommandString(commands[0], ImapResponseHelper.createNonContiguousIdSet(10000, 10316, 2)) + verifyCommandString(commands[1], ImapResponseHelper.createNonContiguousIdSet(10318, 10500, 2)) + } + + @Test + fun splitCommand_withContiguousAndNonContiguousIds_shouldGroupIdsAndSplitCommand() { + val idSet: Set<Long> = ImapResponseHelper.createNonContiguousIdSet(10000, 10298, 2) + + ImapResponseHelper.createNonContiguousIdSet(10402, 10500, 2) + val idGroups = listOf(ContiguousIdGroup(10300L, 10400L)) + val groupedIds = GroupedIds(idSet, idGroups) + + val commands = ImapCommandSplitter.splitCommand(COMMAND_PREFIX, COMMAND_SUFFIX, groupedIds, 980) + + assertThat(commands.size).isEqualTo(2) + assertCommandLengthLimit(commands, 980) + verifyCommandString( + commands[0], + ImapResponseHelper.createNonContiguousIdSet(10000, 10298, 2) + + ImapResponseHelper.createNonContiguousIdSet(10402, 10418, 2), + ) + verifyCommandString(commands[1], ImapResponseHelper.createNonContiguousIdSet(10420, 10500, 2), "10300:10400") + } + + @Test + fun splitCommand_withEmptySuffix_shouldCreateCommandWithoutTrailingSpace() { + val ids = ImapResponseHelper.createNonContiguousIdSet(1, 2, 1) + val groupedIds = GroupedIds(ids, emptyList()) + + val commands = ImapCommandSplitter.splitCommand("UID SEARCH UID", "", groupedIds, 980) + + assertThat(commands.size).isEqualTo(1) + assertThat(commands[0]).isEqualTo("UID SEARCH UID 1,2") + } + + private fun assertCommandLengthLimit(commands: List<String>, lengthLimit: Int) { + for (command in commands) { + assertThat(command.length, "Command is too long").isLessThanOrEqualTo(lengthLimit) + } + } + + private fun verifyCommandString(actualCommand: String, ids: Set<Long>, idGroupString: String? = null) { + val sortedIds: Set<Long> = TreeSet(ids) + val expectedCommandBuilder = StringBuilder(COMMAND_PREFIX) + .append(" ") + .append(ImapUtility.join(",", sortedIds)) + if (idGroupString != null) { + expectedCommandBuilder.append(',').append(idGroupString) + } + expectedCommandBuilder.append(" ").append(COMMAND_SUFFIX) + + val expectedCommand = expectedCommandBuilder.toString() + + assertThat(actualCommand).isEqualTo(expectedCommand) + } + + companion object { + private const val COMMAND_PREFIX = "UID COPY" + private const val COMMAND_SUFFIX = "\"Destination\"" + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapListTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapListTest.java new file mode 100644 index 0000000..ca9d5bd --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapListTest.java @@ -0,0 +1,142 @@ +package com.fsck.k9.mail.store.imap; + +import com.fsck.k9.mail.MessagingException; + +import org.junit.Test; + +import java.io.IOException; +import java.util.Calendar; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + + +public class ImapListTest { + + private ImapList buildSampleList() { + ImapList list = new ImapList(); + list.add("ONE"); + list.add("TWO"); + list.add("THREE"); + return list; + } + + @Test public void containsKey_returnsTrueForKeys() throws IOException { + ImapList list = buildSampleList(); + + assertTrue(list.containsKey("ONE")); + assertTrue(list.containsKey("TWO")); + assertFalse(list.containsKey("THREE")); + assertFalse(list.containsKey("nonexistent")); + } + + @Test public void containsKey_returnsFalseForStringThatCantBeKey() throws IOException { + ImapList list = buildSampleList(); + + assertFalse(list.containsKey("THREE")); + } + + @Test public void containsKey_returnsFalseForStringNotInList() throws IOException { + ImapList list = buildSampleList(); + + assertFalse(list.containsKey("nonexistent")); + } + + @Test + public void getKeyedValue_providesCorrespondingValues() { + ImapList list = buildSampleList(); + + assertEquals("TWO", list.getKeyedValue("ONE")); + assertEquals("THREE", list.getKeyedValue("TWO")); + assertNull(list.getKeyedValue("THREE")); + assertNull(list.getKeyedValue("nonexistent")); + } + + @Test + public void getKeyIndex_providesIndexForKeys() { + ImapList list = buildSampleList(); + + assertEquals(0, list.getKeyIndex("ONE")); + assertEquals(1, list.getKeyIndex("TWO")); + } + + @Test(expected = IllegalArgumentException.class) + public void getKeyIndex_throwsExceptionForValue() { + ImapList list = buildSampleList(); + + list.getKeyIndex("THREE"); + } + + @Test(expected = IllegalArgumentException.class) + public void getKeyIndex_throwsExceptionForNonExistantKey() { + ImapList list = buildSampleList(); + + list.getKeyIndex("nonexistent"); + } + + @Test + public void getDate_returnsCorrectDateForValidString() throws MessagingException { + ImapList list = new ImapList(); + list.add("INTERNALDATE"); + list.add("10-Mar-2000 12:02:01 GMT"); + + Calendar c = Calendar.getInstance(); + c.setTime(list.getDate(1)); + + assertEquals(2000, c.get(Calendar.YEAR)); + assertEquals(Calendar.MARCH, c.get(Calendar.MONTH)); + assertEquals(10, c.get(Calendar.DAY_OF_MONTH)); + } + + @Test(expected = MessagingException.class) + public void getDate_throwsExceptionForInvalidDate() throws MessagingException { + ImapList list = new ImapList(); + list.add("INTERNALDATE"); + list.add("InvalidDate"); + + list.getDate(1); + } + + @Test + public void getDate_returnsNullForNIL() throws MessagingException { + ImapList list = new ImapList(); + list.add("INTERNALDATE"); + list.add("NIL"); + + assertNull(list.getDate(1)); + } + + @Test + public void getKeyedDate_returnsCorrectDateForValidString() throws MessagingException { + ImapList list = new ImapList(); + list.add("INTERNALDATE"); + list.add("10-Mar-2000 12:02:01 GMT"); + + Calendar c = Calendar.getInstance(); + c.setTime(list.getKeyedDate("INTERNALDATE")); + + assertEquals(2000, c.get(Calendar.YEAR)); + assertEquals(Calendar.MARCH, c.get(Calendar.MONTH)); + assertEquals(10, c.get(Calendar.DAY_OF_MONTH)); + } + + @Test(expected = MessagingException.class) + public void getKeyedDate_throwsExceptionForInvalidDate() throws MessagingException { + ImapList list = new ImapList(); + list.add("INTERNALDATE"); + list.add("InvalidDate"); + + list.getKeyedDate("INTERNALDATE"); + } + + @Test + public void getKeyedDate_returnsNullForNIL() throws MessagingException { + ImapList list = new ImapList(); + list.add("INTERNALDATE"); + list.add("NIL"); + + assertNull(list.getKeyedDate("INTERNALDATE")); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseHelper.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseHelper.java new file mode 100644 index 0000000..5eb48ec --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseHelper.java @@ -0,0 +1,38 @@ +package com.fsck.k9.mail.store.imap; + + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.fsck.k9.mail.filter.PeekableInputStream; + + +public class ImapResponseHelper { + public static List<ImapResponse> createImapResponseList(String... responses) throws IOException { + List<ImapResponse> imapResponses = new ArrayList<>(); + for (String response : responses) { + imapResponses.add(createImapResponse(response)); + } + return imapResponses; + } + + public static ImapResponse createImapResponse(String response) throws IOException { + String input = response + "\r\n"; + PeekableInputStream inputStream = new PeekableInputStream(new ByteArrayInputStream(input.getBytes())); + ImapResponseParser parser = new ImapResponseParser(inputStream); + + return parser.readResponse(); + } + + public static Set<Long> createNonContiguousIdSet(long start, long end, int interval) { + Set<Long> ids = new HashSet<>(); + for (long i = start;i <= end;i += interval) { + ids.add(i); + } + return ids; + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.kt new file mode 100644 index 0000000..dfaa75b --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.kt @@ -0,0 +1,521 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.filter.FixedLengthInputStream +import com.fsck.k9.mail.filter.PeekableInputStream +import java.io.ByteArrayInputStream +import java.io.IOException +import org.junit.Assert +import org.junit.Test + +class ImapResponseParserTest { + private var peekableInputStream: PeekableInputStream? = null + @Test + @Throws(IOException::class) + fun testSimpleOkResponse() { + val parser = createParser("* OK\r\n") + val response = parser.readResponse() + Assert.assertNotNull(response) + Assert.assertEquals(1, response.size.toLong()) + Assert.assertEquals("OK", response[0]) + } + + @Test + @Throws(IOException::class) + fun testOkResponseWithText() { + val parser = createParser("* OK Some text here\r\n") + val response = parser.readResponse() + Assert.assertNotNull(response) + Assert.assertEquals(2, response.size.toLong()) + Assert.assertEquals("OK", response[0]) + Assert.assertEquals("Some text here", response[1]) + } + + @Test + @Throws(IOException::class) + fun testOkResponseWithRespTextCode() { + val parser = createParser("* OK [UIDVALIDITY 3857529045]\r\n") + val response = parser.readResponse() + Assert.assertNotNull(response) + Assert.assertEquals(2, response.size.toLong()) + Assert.assertEquals("OK", response[0]) + Assert.assertTrue(response[1] is ImapList) + val respTextCode = response[1] as ImapList + Assert.assertEquals(2, respTextCode.size.toLong()) + Assert.assertEquals("UIDVALIDITY", respTextCode[0]) + Assert.assertEquals("3857529045", respTextCode[1]) + } + + @Test + @Throws(IOException::class) + fun testOkResponseWithRespTextCodeAndText() { + val parser = createParser("* OK [token1 token2] {x} test [...]\r\n") + val response = parser.readResponse() + Assert.assertNotNull(response) + Assert.assertEquals(3, response.size.toLong()) + Assert.assertEquals("OK", response[0]) + Assert.assertTrue(response[1] is ImapList) + Assert.assertEquals("{x} test [...]", response[2]) + val respTextCode = response[1] as ImapList + Assert.assertEquals(2, respTextCode.size.toLong()) + Assert.assertEquals("token1", respTextCode[0]) + Assert.assertEquals("token2", respTextCode[1]) + } + + @Test + @Throws(Exception::class) + fun testReadStatusResponseWithOKResponse() { + val parser = createParser( + "* COMMAND BAR\tBAZ\r\n" + + "TAG OK COMMAND completed\r\n" + ) + val responses = parser.readStatusResponse("TAG", null, null, null) + Assert.assertEquals(2, responses.size.toLong()) + Assert.assertEquals(mutableListOf("COMMAND", "BAR", "BAZ"), responses[0]) + Assert.assertEquals(mutableListOf("OK", "COMMAND completed"), responses[1]) + } + + @Test + @Throws(Exception::class) + fun testReadStatusResponseUntaggedHandlerGetsUntaggedOnly() { + val parser = createParser( + """ + * UNTAGGED + A2 OK COMMAND completed + + """.trimIndent() + ) + val untaggedHandler = TestUntaggedHandler() + parser.readStatusResponse("A2", null, null, untaggedHandler) + Assert.assertEquals(1, untaggedHandler.responses.size.toLong()) + Assert.assertEquals(mutableListOf("UNTAGGED"), untaggedHandler.responses[0]) + } + + @Test + @Throws(Exception::class) + fun testReadStatusResponseSkippingWrongTag() { + val parser = createParser( + """ + * UNTAGGED + * 0 EXPUNGE + * 42 EXISTS + A1 COMMAND BAR BAZ + A2 OK COMMAND completed + + """.trimIndent() + ) + val untaggedHandler = TestUntaggedHandler() + val responses = parser.readStatusResponse("A2", null, null, untaggedHandler) + Assert.assertEquals(3, responses.size.toLong()) + Assert.assertEquals(mutableListOf("0", "EXPUNGE"), responses[0]) + Assert.assertEquals(mutableListOf("42", "EXISTS"), responses[1]) + Assert.assertEquals(mutableListOf("OK", "COMMAND completed"), responses[2]) + Assert.assertEquals(mutableListOf("UNTAGGED"), untaggedHandler.responses[0]) + Assert.assertEquals(responses[0], untaggedHandler.responses[1]) + Assert.assertEquals(responses[1], untaggedHandler.responses[2]) + } + + @Test + @Throws(Exception::class) + fun testReadStatusResponseUntaggedHandlerStillCalledOnNegativeReply() { + val parser = createParser( + """ + + text + A2 NO Bad response + + """.trimIndent() + ) + val untaggedHandler = TestUntaggedHandler() + try { + val responses = parser.readStatusResponse("A2", null, null, untaggedHandler) + } catch (e: NegativeImapResponseException) { + } + Assert.assertEquals(1, untaggedHandler.responses.size.toLong()) + Assert.assertEquals(mutableListOf("text"), untaggedHandler.responses[0]) + } + + @Test(expected = NegativeImapResponseException::class) + @Throws(Exception::class) + fun testReadStatusResponseWithErrorResponse() { + val parser = createParser("* COMMAND BAR BAZ\r\nTAG ERROR COMMAND errored\r\n") + parser.readStatusResponse("TAG", null, null, null) + } + + @Test + @Throws(Exception::class) + fun testRespTextCodeWithList() { + val parser = createParser( + """ + * OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft NonJunk ${"$"}MDNSent \*)] Flags permitted. + + """.trimIndent() + ) + val response = parser.readResponse() + Assert.assertEquals(3, response.size.toLong()) + Assert.assertTrue(response[1] is ImapList) + Assert.assertEquals(2, response.getList(1).size.toLong()) + Assert.assertEquals("PERMANENTFLAGS", response.getList(1).getString(0)) + Assert.assertTrue(response.getList(1)[1] is ImapList) + Assert.assertEquals("\\Answered", response.getList(1).getList(1).getString(0)) + Assert.assertEquals("\\Flagged", response.getList(1).getList(1).getString(1)) + Assert.assertEquals("\\Deleted", response.getList(1).getList(1).getString(2)) + Assert.assertEquals("\\Seen", response.getList(1).getList(1).getString(3)) + Assert.assertEquals("\\Draft", response.getList(1).getList(1).getString(4)) + Assert.assertEquals("NonJunk", response.getList(1).getList(1).getString(5)) + Assert.assertEquals("\$MDNSent", response.getList(1).getList(1).getString(6)) + Assert.assertEquals("\\*", response.getList(1).getList(1).getString(7)) + } + + @Test + @Throws(Exception::class) + fun testExistsResponse() { + val parser = createParser("* 23 EXISTS\r\n") + val response = parser.readResponse() + Assert.assertEquals(2, response.size.toLong()) + Assert.assertEquals(23, response.getNumber(0).toLong()) + Assert.assertEquals("EXISTS", response.getString(1)) + } + + @Test(expected = IOException::class) + @Throws(IOException::class) + fun testReadStringUntilEndOfStream() { + val parser = createParser("* OK Some text ") + parser.readResponse() + } + + @Test + @Throws(Exception::class) + fun testCommandContinuation() { + val parser = createParser("+ Ready for additional command text\r\n") + val response = parser.readResponse() + Assert.assertEquals(1, response.size.toLong()) + Assert.assertEquals("Ready for additional command text", response.getString(0)) + } + + @Test + @Throws(Exception::class) + fun testParseLiteral() { + val parser = createParser("* {4}\r\ntest\r\n") + val response = parser.readResponse() + Assert.assertEquals(1, response.size.toLong()) + Assert.assertEquals("test", response.getString(0)) + } + + @Test + @Throws(Exception::class) + fun testParseLiteralWithEmptyString() { + val parser = createParser("* {0}\r\n\r\n") + val response = parser.readResponse() + Assert.assertEquals(1, response.size.toLong()) + Assert.assertEquals("", response.getString(0)) + } + + @Test(expected = IOException::class) + @Throws(Exception::class) + fun testParseLiteralToEndOfStream() { + val parser = createParser("* {4}\r\nabc") + parser.readResponse() + } + + @Test + @Throws(Exception::class) + fun testParseLiteralWithConsumingCallbackReturningNull() { + val parser = createParser("* {4}\r\ntest\r\n") + val callback = TestImapResponseCallback.readBytesAndReturn(4, "cheeseburger") + val response = parser.readResponse(callback) + Assert.assertEquals(1, response.size.toLong()) + Assert.assertEquals("cheeseburger", response.getString(0)) + } + + @Test + @Throws(Exception::class) + fun testParseLiteralWithNonConsumingCallbackReturningNull() { + val parser = createParser("* {4}\r\ntest\r\n") + val callback = TestImapResponseCallback.readBytesAndReturn(0, null) + val response = parser.readResponse(callback) + Assert.assertEquals(1, response.size.toLong()) + Assert.assertEquals("test", response.getString(0)) + Assert.assertTrue(callback.foundLiteralCalled) + assertAllInputConsumed() + } + + @Test + @Throws(Exception::class) + fun readResponse_withPartlyConsumingCallbackReturningNull_shouldThrow() { + val parser = createParser("* {4}\r\ntest\r\n") + val callback = TestImapResponseCallback.readBytesAndReturn(2, null) + try { + parser.readResponse(callback) + Assert.fail() + } catch (e: AssertionError) { + Assert.assertEquals("Callback consumed some data but returned no result", e.message) + } + } + + @Test + @Throws(Exception::class) + fun readResponse_withPartlyConsumingCallbackThatThrows_shouldReadAllDataAndThrow() { + val parser = createParser("* {4}\r\ntest\r\n") + val callback = TestImapResponseCallback.readBytesAndThrow(2) + try { + parser.readResponse(callback) + Assert.fail() + } catch (e: ImapResponseParserException) { + Assert.assertEquals("readResponse(): Exception in callback method", e.message) + Assert.assertEquals(ImapResponseParserTestException::class.java, e.cause!!.javaClass) + } + assertAllInputConsumed() + } + + @Test + @Throws(Exception::class) + fun readResponse_withCallbackThatThrowsRepeatedly_shouldConsumeAllInputAndThrowFirstException() { + val parser = createParser("* {3}\r\none {3}\r\ntwo\r\n") + val callback = TestImapResponseCallback.readBytesAndThrow(3) + try { + parser.readResponse(callback) + Assert.fail() + } catch (e: ImapResponseParserException) { + Assert.assertEquals("readResponse(): Exception in callback method", e.message) + Assert.assertEquals(ImapResponseParserTestException::class.java, e.cause!!.javaClass) + Assert.assertEquals(0, (e.cause as ImapResponseParserTestException?)!!.instanceNumber.toLong()) + } + assertAllInputConsumed() + } + + @Test + @Throws(Exception::class) + fun testParseLiteralWithIncompleteConsumingCallbackReturningString() { + val parser = createParser("* {4}\r\ntest\r\n") + val callback = TestImapResponseCallback.readBytesAndReturn(2, "ninja") + val response = parser.readResponse(callback) + Assert.assertEquals(1, response.size.toLong()) + Assert.assertEquals("ninja", response.getString(0)) + assertAllInputConsumed() + } + + @Test + @Throws(Exception::class) + fun testParseLiteralWithThrowingCallback() { + val parser = createParser("* {4}\r\ntest\r\n") + val callback: ImapResponseCallback = TestImapResponseCallback.readBytesAndThrow(0) + try { + parser.readResponse(callback) + Assert.fail() + } catch (e: ImapResponseParserException) { + Assert.assertEquals("readResponse(): Exception in callback method", e.message) + } + assertAllInputConsumed() + } + + @Test(expected = IOException::class) + @Throws(Exception::class) + fun testParseLiteralWithCallbackThrowingIOException() { + val parser = createParser("* {4}\r\ntest\r\n") + val callback = ImapResponseCallback { response, literal -> throw IOException() } + parser.readResponse(callback) + } + + @Test + @Throws(Exception::class) + fun testParseQuoted() { + val parser = createParser("* \"qu\\\"oted\"\r\n") + val response = parser.readResponse() + Assert.assertEquals(1, response.size.toLong()) + Assert.assertEquals("qu\"oted", response.getString(0)) + } + + @Test + @Throws(Exception::class) + fun utf8InQuotedString() { + val parser = createParser("* \"quöted\"\r\n") + val response = parser.readResponse() + Assert.assertEquals(1, response.size.toLong()) + Assert.assertEquals("quöted", response.getString(0)) + } + + @Test(expected = IOException::class) + @Throws(Exception::class) + fun testParseQuotedToEndOfStream() { + val parser = createParser("* \"abc") + parser.readResponse() + } + + @Test(expected = IOException::class) + @Throws(Exception::class) + fun testParseAtomToEndOfStream() { + val parser = createParser("* abc") + parser.readResponse() + } + + @Test(expected = IOException::class) + @Throws(Exception::class) + fun testParseUntaggedResponseWithoutSpace() { + val parser = createParser("*\r\n") + parser.readResponse() + } + + @Test + @Throws(Exception::class) + fun testListResponseContainingFolderNameWithBrackets() { + val parser = createParser("* LIST (\\HasNoChildren) \".\" [FolderName]\r\n") + val response = parser.readResponse() + Assert.assertEquals(4, response.size.toLong()) + Assert.assertEquals("LIST", response[0]) + Assert.assertEquals(1, response.getList(1).size.toLong()) + Assert.assertEquals("\\HasNoChildren", response.getList(1).getString(0)) + Assert.assertEquals(".", response[2]) + Assert.assertEquals("[FolderName]", response[3]) + } + + @Test(expected = IOException::class) + @Throws(Exception::class) + fun testListResponseContainingFolderNameContainingBracketsThrowsException() { + val parser = createParser( + "* LIST (\\NoInferiors) \"/\" Root/Folder/Subfolder()\r\n" + ) + parser.readResponse() + } + + @Test + @Throws(Exception::class) + fun readResponseShouldReadWholeListResponseLine() { + val parser = createParser( + """* LIST (\HasNoChildren) "." [FolderName] +TAG OK [List complete] +""" + ) + parser.readResponse() + val responseTwo = parser.readResponse() + Assert.assertEquals("TAG", responseTwo.tag) + } + + @Test + @Throws(Exception::class) + fun readResponse_withListResponseContainingNil() { + val parser = createParser("* LIST (\\NoInferiors) NIL INBOX\r\n") + val response = parser.readResponse() + Assert.assertEquals(4, response.size.toLong()) + Assert.assertEquals("LIST", response[0]) + Assert.assertEquals(1, response.getList(1).size.toLong()) + Assert.assertEquals("\\NoInferiors", response.getList(1).getString(0)) + Assert.assertEquals(null, response[2]) + Assert.assertEquals("INBOX", response[3]) + } + + @Test + @Throws(Exception::class) + fun readResponse_withListAsFirstToken_shouldThrow() { + val parser = createParser("* [1 2] 3\r\n") + try { + parser.readResponse() + Assert.fail("Expected exception") + } catch (e: IOException) { + Assert.assertEquals("Unexpected non-string token: ImapList - [1, 2]", e.message) + } + } + + @Test + @Throws(Exception::class) + fun testFetchResponse() { + val parser = createParser( + """* 1 FETCH (UID 23 INTERNALDATE "01-Jul-2015 12:34:56 +0200" RFC822.SIZE 3456 BODY[HEADER.FIELDS (date subject from)] "<headers>" FLAGS (\Seen)) +""" + ) + val response = parser.readResponse() + Assert.assertEquals(3, response.size.toLong()) + Assert.assertEquals("1", response.getString(0)) + Assert.assertEquals("FETCH", response.getString(1)) + Assert.assertEquals("UID", response.getList(2).getString(0)) + Assert.assertEquals(23, response.getList(2).getNumber(1).toLong()) + Assert.assertEquals("INTERNALDATE", response.getList(2).getString(2)) + Assert.assertEquals("01-Jul-2015 12:34:56 +0200", response.getList(2).getString(3)) + Assert.assertEquals("RFC822.SIZE", response.getList(2).getString(4)) + Assert.assertEquals(3456, response.getList(2).getNumber(5).toLong()) + Assert.assertEquals("BODY", response.getList(2).getString(6)) + Assert.assertEquals(2, response.getList(2).getList(7).size.toLong()) + Assert.assertEquals("HEADER.FIELDS", response.getList(2).getList(7).getString(0)) + Assert.assertEquals(3, response.getList(2).getList(7).getList(1).size.toLong()) + Assert.assertEquals("date", response.getList(2).getList(7).getList(1).getString(0)) + Assert.assertEquals("subject", response.getList(2).getList(7).getList(1).getString(1)) + Assert.assertEquals("from", response.getList(2).getList(7).getList(1).getString(2)) + Assert.assertEquals("<headers>", response.getList(2).getString(8)) + Assert.assertEquals("FLAGS", response.getList(2).getString(9)) + Assert.assertEquals(1, response.getList(2).getList(10).size.toLong()) + Assert.assertEquals("\\Seen", response.getList(2).getList(10).getString(0)) + } + + @Test + @Throws(Exception::class) + fun readStatusResponse_withNoResponse_shouldThrow() { + val parser = createParser("1 NO\r\n") + try { + parser.readStatusResponse("1", "COMMAND", "[logId]", null) + Assert.fail("Expected exception") + } catch (e: NegativeImapResponseException) { + Assert.assertEquals("Command: COMMAND; response: #1# [NO]", e.message) + } + } + + @Test + @Throws(Exception::class) + fun readStatusResponse_withNoResponseAndAlertText_shouldThrowWithAlertText() { + val parser = createParser("1 NO [ALERT] Access denied\r\n") + try { + parser.readStatusResponse("1", "COMMAND", "[logId]", null) + Assert.fail("Expected exception") + } catch (e: NegativeImapResponseException) { + Assert.assertEquals("Access denied", e.alertText) + } + } + + private fun createParser(response: String): ImapResponseParser { + val byteArrayInputStream = ByteArrayInputStream(response.toByteArray(UTF_8)) + peekableInputStream = PeekableInputStream(byteArrayInputStream) + return ImapResponseParser(peekableInputStream) + } + + @Throws(IOException::class) + private fun assertAllInputConsumed() { + Assert.assertEquals(0, peekableInputStream!!.available().toLong()) + } + + internal class TestImapResponseCallback private constructor( + private val readNumberOfBytes: Int, + private val returnValue: Any, + private val throwException: Boolean + ) : ImapResponseCallback { + private var exceptionCount = 0 + var foundLiteralCalled = false + @Throws(Exception::class) + override fun foundLiteral(response: ImapResponse, literal: FixedLengthInputStream): Any { + foundLiteralCalled = true + var skipBytes = readNumberOfBytes + while (skipBytes > 0) { + val skippedBytes = literal.skip(skipBytes.toLong()) + skipBytes -= skippedBytes.toInt() + } + if (throwException) { + throw ImapResponseParserTestException(exceptionCount++) + } + return returnValue + } + + companion object { + fun readBytesAndReturn(readNumberOfBytes: Int, returnValue: Any?): TestImapResponseCallback { + return TestImapResponseCallback(readNumberOfBytes, returnValue!!, false) + } + + fun readBytesAndThrow(readNumberOfBytes: Int): TestImapResponseCallback { + return TestImapResponseCallback(readNumberOfBytes, null, true) + } + } + } + + internal class ImapResponseParserTestException(val instanceNumber: Int) : RuntimeException() + internal class TestUntaggedHandler : UntaggedHandler { + val responses: MutableList<ImapResponse> = ArrayList() + override fun handleAsyncUntaggedResponse(response: ImapResponse) { + responses.add(response) + } + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapUtilityTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapUtilityTest.java new file mode 100644 index 0000000..da285d8 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapUtilityTest.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2012 The K-9 Dog Walkers + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.fsck.k9.mail.store.imap; + + +import java.util.List; + +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; + + +public class ImapUtilityTest { + @Test + public void testGetImapSequenceValues() { + String[] expected; + List<String> actual; + + // Test valid sets + expected = new String[] {"1"}; + actual = ImapUtility.getImapSequenceValues("1"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[] {"2147483648"}; // Integer.MAX_VALUE + 1 + actual = ImapUtility.getImapSequenceValues("2147483648"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[] {"4294967295"}; // 2^32 - 1 + actual = ImapUtility.getImapSequenceValues("4294967295"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[] {"1", "3", "2"}; + actual = ImapUtility.getImapSequenceValues("1,3,2"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[] {"4", "5", "6"}; + actual = ImapUtility.getImapSequenceValues("4:6"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[] {"9", "8", "7"}; + actual = ImapUtility.getImapSequenceValues("9:7"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[] {"1", "2", "3", "4", "9", "8", "7"}; + actual = ImapUtility.getImapSequenceValues("1,2:4,9:7"); + assertArrayEquals(expected, actual.toArray()); + + // Test numbers larger than Integer.MAX_VALUE (2147483647) + expected = new String[] {"2147483646", "2147483647", "2147483648"}; + actual = ImapUtility.getImapSequenceValues("2147483646:2147483648"); + assertArrayEquals(expected, actual.toArray()); + + // Test partially invalid sets + expected = new String[] { "1", "5" }; + actual = ImapUtility.getImapSequenceValues("1,x,5"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[] { "1", "2", "3" }; + actual = ImapUtility.getImapSequenceValues("a:d,1:3"); + assertArrayEquals(expected, actual.toArray()); + + // Test invalid sets + expected = new String[0]; + actual = ImapUtility.getImapSequenceValues(""); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[0]; + actual = ImapUtility.getImapSequenceValues(null); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[0]; + actual = ImapUtility.getImapSequenceValues("a"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[0]; + actual = ImapUtility.getImapSequenceValues("1:x"); + assertArrayEquals(expected, actual.toArray()); + + // Test values larger than 2^32 - 1 + expected = new String[0]; + actual = ImapUtility.getImapSequenceValues("4294967296:4294967297"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[0]; + actual = ImapUtility.getImapSequenceValues("4294967296"); // 2^32 + assertArrayEquals(expected, actual.toArray()); + } + + @Test public void testGetImapRangeValues() { + String[] expected; + List<String> actual; + + // Test valid ranges + expected = new String[] {"1", "2", "3"}; + actual = ImapUtility.getImapRangeValues("1:3"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[] {"16", "15", "14"}; + actual = ImapUtility.getImapRangeValues("16:14"); + assertArrayEquals(expected, actual.toArray()); + + // Test in-valid ranges + expected = new String[0]; + actual = ImapUtility.getImapRangeValues(""); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[0]; + actual = ImapUtility.getImapRangeValues(null); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[0]; + actual = ImapUtility.getImapRangeValues("a"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[0]; + actual = ImapUtility.getImapRangeValues("6"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[0]; + actual = ImapUtility.getImapRangeValues("1:3,6"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[0]; + actual = ImapUtility.getImapRangeValues("1:x"); + assertArrayEquals(expected, actual.toArray()); + + expected = new String[0]; + actual = ImapUtility.getImapRangeValues("1:*"); + assertArrayEquals(expected, actual.toArray()); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ListResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ListResponseTest.java new file mode 100644 index 0000000..d27ec6f --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ListResponseTest.java @@ -0,0 +1,99 @@ +package com.fsck.k9.mail.store.imap; + + +import java.io.IOException; +import java.util.List; + +import org.junit.Test; + +import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.junit.Assert.assertEquals; + + +public class ListResponseTest { + @Test + public void parseList_withValidResponses_shouldReturnListResponses() throws Exception { + List<ImapResponse> responses = asList( + createImapResponse("* LIST () \"/\" blurdybloop"), + createImapResponse("* LIST (\\Noselect) \"/\" foo"), + createImapResponse("* LIST () \"/\" foo/bar"), + createImapResponse("* LIST (\\NoInferiors) NIL INBOX"), + createImapResponse("X OK LIST completed") + ); + + List<ListResponse> result = ListResponse.parseList(responses); + + assertEquals(4, result.size()); + assertListResponseEquals(noAttributes(), "/", "blurdybloop", result.get(0)); + assertListResponseEquals(singletonList("\\Noselect"), "/", "foo", result.get(1)); + assertListResponseEquals(noAttributes(), "/", "foo/bar", result.get(2)); + assertListResponseEquals(singletonList("\\NoInferiors"), null, "INBOX", result.get(3)); + } + + @Test + public void parseList_withValidResponse_shouldReturnListResponse() throws Exception { + List<ListResponse> result = parseSingle("* LIST () \".\" \"Folder\""); + + assertEquals(1, result.size()); + assertListResponseEquals(noAttributes(), ".", "Folder", result.get(0)); + } + + @Test + public void parseList_withValidResponseContainingAttributes_shouldReturnListResponse() throws Exception { + List<ListResponse> result = parseSingle("* LIST (\\HasChildren \\Noselect) \".\" \"Folder\""); + + assertEquals(1, result.size()); + assertListResponseEquals(asList("\\HasChildren", "\\Noselect"), ".", "Folder", result.get(0)); + } + + @Test + public void parseList_withoutListResponse_shouldReturnEmptyList() throws Exception { + List<ListResponse> result = parseSingle("* LSUB () \".\" INBOX"); + + assertEquals(emptyList(), result); + } + + @Test + public void parseList_withMalformedListResponse1_shouldReturnEmptyList() throws Exception { + List<ListResponse> result = parseSingle("* LIST ([inner list]) \"/\" \"Folder\""); + + assertEquals(emptyList(), result); + } + + @Test + public void parseList_withMalformedListResponse2_shouldReturnEmptyList() throws Exception { + List<ListResponse> result = parseSingle("* LIST () \"ab\" \"Folder\""); + + assertEquals(emptyList(), result); + } + + @Test + public void parseLsub_withValidResponse_shouldReturnListResponse() throws Exception { + List<ImapResponse> responses = singletonList(createImapResponse("* LSUB () \".\" \"Folder\"")); + + List<ListResponse> result = ListResponse.parseLsub(responses); + + assertEquals(1, result.size()); + assertListResponseEquals(noAttributes(), ".", "Folder", result.get(0)); + } + + private List<ListResponse> parseSingle(String response) throws IOException { + List<ImapResponse> responses = singletonList(createImapResponse(response)); + + return ListResponse.parseList(responses); + } + + private List<String> noAttributes() { + return emptyList(); + } + + private void assertListResponseEquals(List<String> attributes, String delimiter, String name, + ListResponse listResponse) { + assertEquals(attributes, listResponse.getAttributes()); + assertEquals(delimiter, listResponse.getHierarchyDelimiter()); + assertEquals(name, listResponse.getName()); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/NamespaceResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/NamespaceResponseTest.java new file mode 100644 index 0000000..2c157ff --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/NamespaceResponseTest.java @@ -0,0 +1,103 @@ +package com.fsck.k9.mail.store.imap; + + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; + +import org.junit.Test; + +import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + + +public class NamespaceResponseTest { + + @Test + public void parse_withProperNamespaceResponse() throws Exception { + NamespaceResponse result = parse("* NAMESPACE ((\"\" \"/\")) NIL NIL"); + + assertNotNull(result); + assertEquals("", result.getPrefix()); + assertEquals("/", result.getHierarchyDelimiter()); + } + + @Test + public void parse_withoutNamespaceResponse_shouldReturnNull() throws Exception { + NamespaceResponse result = parse("* OK Some text here"); + + assertNull(result); + } + + @Test + public void parse_withTooShortNamespaceResponse_shouldReturnNull() throws Exception { + NamespaceResponse result = parse("* NAMESPACE NIL NIL"); + + assertNull(result); + } + + @Test + public void parse_withPersonalNamespacesNotPresent_shouldReturnNull() throws Exception { + NamespaceResponse result = parse("* NAMESPACE NIL NIL NIL"); + + assertNull(result); + } + + @Test + public void parse_withEmptyListForPersonalNamespaces_shouldReturnNull() throws Exception { + NamespaceResponse result = parse("* NAMESPACE () NIL NIL"); + + assertNull(result); + } + + @Test + public void parse_withEmptyListForFirstPersonalNamespace_shouldReturnNull() throws Exception { + NamespaceResponse result = parse("* NAMESPACE (()) NIL NIL"); + + assertNull(result); + } + + @Test + public void parse_withIncompleteFirstPersonalNamespace_shouldReturnNull() throws Exception { + NamespaceResponse result = parse("* NAMESPACE ((\"\")) NIL NIL"); + + assertNull(result); + } + + @Test + public void parse_withEmptyResponseList() throws Exception { + NamespaceResponse result = NamespaceResponse.parse(Collections.<ImapResponse>emptyList()); + + assertNull(result); + } + + @Test + public void parse_withSingleItemInResponseList() throws Exception { + ImapResponse imapResponse = createImapResponse("* NAMESPACE ((\"\" \"/\")) NIL NIL"); + NamespaceResponse result = NamespaceResponse.parse(Collections.singletonList(imapResponse)); + + assertNotNull(result); + assertEquals("", result.getPrefix()); + assertEquals("/", result.getHierarchyDelimiter()); + } + + @Test + public void parse_withResponseList() throws Exception { + ImapResponse imapResponseOne = createImapResponse("* OK"); + ImapResponse imapResponseTwo = createImapResponse("* NAMESPACE ((\"INBOX\" \".\")) NIL NIL"); + NamespaceResponse result = NamespaceResponse.parse(Arrays.asList(imapResponseOne, imapResponseTwo)); + + assertNotNull(result); + assertEquals("INBOX", result.getPrefix()); + assertEquals(".", result.getHierarchyDelimiter()); + } + + + private NamespaceResponse parse(String response) throws IOException { + ImapResponse imapResponse = createImapResponse(response); + + return NamespaceResponse.parse(imapResponse); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponseTest.java new file mode 100644 index 0000000..bad8a46 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponseTest.java @@ -0,0 +1,109 @@ +package com.fsck.k9.mail.store.imap; + + +import com.fsck.k9.mail.Flag; +import org.junit.Test; + +import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.internal.util.collections.Sets.newSet; + + +public class PermanentFlagsResponseTest { + @Test + public void parse_withPermanentFlagsResponse_shouldExtractFlags() throws Exception { + ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen " + + "\\Draft $forwarded NonJunk $label1 \\*)] Flags permitted."); + + PermanentFlagsResponse result = PermanentFlagsResponse.parse(response); + + assertNotNull(result); + assertEquals(newSet(Flag.ANSWERED, Flag.FLAGGED, Flag.DELETED, Flag.SEEN, Flag.FORWARDED), result.getFlags()); + } + + @Test + public void parse_withPermanentFlagsResponseContainingSpecialKeyword_shouldSetCanCreateKeywords() throws Exception { + ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Deleted \\*)] Flags permitted."); + + PermanentFlagsResponse result = PermanentFlagsResponse.parse(response); + + assertNotNull(result); + assertEquals(true, result.canCreateKeywords()); + } + + @Test + public void parse_withPermanentFlagsResponseNotContainingSpecialKeyword_shouldNotSetCanCreateKeywords() + throws Exception { + ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Deleted \\Seen)] Flags permitted."); + + PermanentFlagsResponse result = PermanentFlagsResponse.parse(response); + + assertNotNull(result); + assertEquals(false, result.canCreateKeywords()); + } + + @Test + public void parse_withTaggedResponse_shouldReturnNull() throws Exception { + ImapResponse response = createImapResponse("1 OK [PERMANENTFLAGS (\\Deleted \\Seen)] Flags permitted."); + + PermanentFlagsResponse result = PermanentFlagsResponse.parse(response); + + assertNull(result); + } + + @Test + public void parse_withoutOkResponse_shouldReturnNull() throws Exception { + ImapResponse response = createImapResponse("* BYE See you"); + + PermanentFlagsResponse result = PermanentFlagsResponse.parse(response); + + assertNull(result); + } + + @Test + public void parse_withoutResponseText_shouldReturnNull() throws Exception { + ImapResponse response = createImapResponse("* OK Success"); + + PermanentFlagsResponse result = PermanentFlagsResponse.parse(response); + + assertNull(result); + } + + @Test + public void parse_withTooShortResponseText_shouldReturnNull() throws Exception { + ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS]"); + + PermanentFlagsResponse result = PermanentFlagsResponse.parse(response); + + assertNull(result); + } + + @Test + public void parse_withoutPermanentFlagsResponse_shouldReturnNull() throws Exception { + ImapResponse response = createImapResponse("* OK [UIDNEXT 1]"); + + PermanentFlagsResponse result = PermanentFlagsResponse.parse(response); + + assertNull(result); + } + + @Test + public void parse_withoutPermanentFlagsList_shouldReturnNull() throws Exception { + ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS none]"); + + PermanentFlagsResponse result = PermanentFlagsResponse.parse(response); + + assertNull(result); + } + + @Test + public void parse_withInvalidElementInPermanentFlagsList_shouldReturnNull() throws Exception { + ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Seen ())]"); + + PermanentFlagsResponse result = PermanentFlagsResponse.parse(response); + + assertNull(result); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt new file mode 100644 index 0000000..9198d4c --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt @@ -0,0 +1,1136 @@ +package com.fsck.k9.mail.store.imap + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.containsExactly +import assertk.assertions.hasMessage +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isFailure +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isTrue +import assertk.assertions.message +import assertk.assertions.prop +import com.fsck.k9.logging.Timber +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.CertificateValidationException +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.K9MailLib +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.SystemOutLogger +import com.fsck.k9.mail.XOAuth2ChallengeParserTest +import com.fsck.k9.mail.helpers.TestTrustedSocketFactory +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import com.fsck.k9.mail.store.imap.mockserver.MockImapServer +import java.io.IOException +import java.net.UnknownHostException +import okio.ByteString.Companion.encodeUtf8 +import org.junit.Before +import org.junit.Test + +private const val DEBUGGING = false + +private const val USERNAME = "user" +private const val PASSWORD = "123456" + +private const val SOCKET_CONNECT_TIMEOUT = 10000 +private const val SOCKET_READ_TIMEOUT = 10000 + +private const val XOAUTH_TOKEN = "token" +private const val XOAUTH_TOKEN_2 = "token2" +private val XOAUTH_STRING = "user=$USERNAME\u0001auth=Bearer $XOAUTH_TOKEN\u0001\u0001".base64() +private val XOAUTH_STRING_RETRY = "user=$USERNAME\u0001auth=Bearer $XOAUTH_TOKEN_2\u0001\u0001".base64() +private val OAUTHBEARER_STRING = "n,a=$USERNAME,\u0001auth=Bearer $XOAUTH_TOKEN\u0001\u0001".base64() + +class RealImapConnectionTest { + private var socketFactory = TestTrustedSocketFactory.newInstance() + private var oAuth2TokenProvider = TestTokenProvider() + + @Before + fun setUp() { + if (DEBUGGING) { + Timber.logger = SystemOutLogger() + K9MailLib.setDebug(true) + K9MailLib.setDebugSensitive(true) + } + } + + @Test + fun `open() with no capabilities in initial response should issue pre-auth capabilities command`() { + val server = MockImapServer().apply { + output("* OK example.org server") + expect("1 CAPABILITY") + output("* CAPABILITY IMAP4 IMAP4REV1 AUTH=PLAIN") + output("1 OK CAPABILITY Completed") + expect("2 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output("2 OK Success") + postAuthenticationDialogRequestingCapabilities() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with capabilities in initial response should not issue pre-auth capabilities command`() { + val server = MockImapServer().apply { + output("* OK [CAPABILITY IMAP4 IMAP4REV1 AUTH=PLAIN]") + expect("1 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output("1 OK Success") + postAuthenticationDialogRequestingCapabilities(tag = 2) + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH PLAIN`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=PLAIN") + expect("2 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output("2 OK Success") + postAuthenticationDialogRequestingCapabilities() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() after close() was called should throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog() + expect("2 LOGIN \"$USERNAME\" \"$PASSWORD\"") + output("2 OK LOGIN completed") + postAuthenticationDialogRequestingCapabilities() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + imapConnection.open() + imapConnection.close() + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(IllegalStateException::class) + .hasMessage("open() called after close(). Check wrapped exception to see where close() was called.") + } + + @Test + fun `open() AUTH PLAIN with login disabled should throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "LOGINDISABLED") + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Server doesn't support unencrypted passwords using AUTH=PLAIN and LOGIN is disabled.") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH PLAIN with authentication failure should fall back to LOGIN`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=PLAIN") + expect("2 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output("2 NO Login Failure") + expect("3 LOGIN \"$USERNAME\" \"$PASSWORD\"") + output("3 OK LOGIN completed") + postAuthenticationDialogRequestingCapabilities(tag = 4) + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH PLAIN and LOGIN fallback with authentication failure should throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=PLAIN") + expect("2 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output("2 NO Login Failure") + expect("3 LOGIN \"$USERNAME\" \"$PASSWORD\"") + output("3 NO Go away") + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .prop(AuthenticationFailedException::messageFromServer) + .isEqualTo("Go away") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH PLAIN failure and disconnect should throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=PLAIN") + expect("2 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output("2 NO [UNAVAILABLE] Maximum number of connections from user+IP exceeded") + closeConnection() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(NegativeImapResponseException::class) + .message().isNotNull().contains("Maximum number of connections from user+IP exceeded") + + assertThat(imapConnection.isConnected).isFalse() + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH PLAIN with BYE response and connection close should throw AuthenticationFailedException`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=PLAIN") + expect("2 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output("* BYE Go away") + output("2 NO Login Failure") + closeConnection() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .prop(AuthenticationFailedException::messageFromServer) + .isEqualTo("Login Failure") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH PLAIN without AUTH PLAIN capability should use LOGIN command`() { + val server = MockImapServer().apply { + preAuthenticationDialog() + expect("2 LOGIN \"$USERNAME\" \"$PASSWORD\"") + output("2 OK LOGIN completed") + postAuthenticationDialogRequestingCapabilities() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH CRAM-MD5`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=CRAM-MD5") + expect("2 AUTHENTICATE CRAM-MD5") + output("+ ${"<0000.000000000@example.org>".base64()}") + expect("dXNlciA2ZjdiOTcyYjk5YTI4NDk4OTRhN2YyMmE3MGRhZDg0OQ==") + output("2 OK Success") + postAuthenticationDialogRequestingCapabilities() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.CRAM_MD5) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH CRAM-MD5 with authentication failure should throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=CRAM-MD5") + expect("2 AUTHENTICATE CRAM-MD5") + output("+ ${"<0000.000000000@example.org>".base64()}") + expect("dXNlciA2ZjdiOTcyYjk5YTI4NDk4OTRhN2YyMmE3MGRhZDg0OQ==") + output("2 NO Who are you?") + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.CRAM_MD5) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .prop(AuthenticationFailedException::messageFromServer) + .isEqualTo("Who are you?") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH CRAM-MD5 without AUTH CRAM-MD5 capability should throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=PLAIN") + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.CRAM_MD5) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Server doesn't support encrypted passwords using CRAM-MD5.") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH OAUTHBEARER`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "SASL-IR AUTH=OAUTHBEARER") + expect("2 AUTHENTICATE OAUTHBEARER $OAUTHBEARER_STRING") + output("2 OK Success") + postAuthenticationDialogRequestingCapabilities() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH OAUTHBEARER when AUTH=XOAUTH2 and AUTH=OAUTHBEARER capabilities are present`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH2 AUTH=OAUTHBEARER") + expect("2 AUTHENTICATE OAUTHBEARER $OAUTHBEARER_STRING") + output("2 OK Success") + postAuthenticationDialogRequestingCapabilities() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH XOAUTH2 with SASL-IR`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2") + expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING") + output("2 OK Success") + postAuthenticationDialogRequestingCapabilities() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH XOAUTH2 with untagged CAPABILITY response after authentication`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2") + expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING") + output("* CAPABILITY IMAP4rev1 X-GM-EXT-1") + output("2 OK Success") + simplePostAuthenticationDialog(tag = 3) + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2) + + imapConnection.open() + + assertThat(imapConnection.hasCapability("X-GM-EXT-1")).isTrue() + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH XOAUTH2 throws exception on 401 response`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2") + expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING") + output("+ ${XOAuth2ChallengeParserTest.STATUS_401_RESPONSE}") + expect("") + output("2 NO SASL authentication failed") + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .prop(AuthenticationFailedException::messageFromServer) + .isEqualTo("SASL authentication failed") + } + + @Test + fun `open() AUTH XOAUTH2 invalidates and retries new token on 400 response`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2") + expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING") + output("+ ${XOAuth2ChallengeParserTest.STATUS_400_RESPONSE}") + expect("") + output("2 NO SASL authentication failed") + expect("3 AUTHENTICATE XOAUTH2 $XOAUTH_STRING_RETRY") + output("3 OK Success") + postAuthenticationDialogRequestingCapabilities(tag = 4) + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH XOAUTH2 invalidates and retries new token on invalid JSON response`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2") + expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING") + output("+ ${XOAuth2ChallengeParserTest.INVALID_RESPONSE}") + expect("") + output("2 NO SASL authentication failed") + expect("3 AUTHENTICATE XOAUTH2 $XOAUTH_STRING_RETRY") + output("3 OK Success") + requestCapabilities(tag = 4) + simplePostAuthenticationDialog(tag = 5) + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH XOAUTH2 invalidates and retries new token on missing status JSON response`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2") + expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING") + output("+ ${XOAuth2ChallengeParserTest.MISSING_STATUS_RESPONSE}") + expect("") + output("2 NO SASL authentication failed") + expect("3 AUTHENTICATE XOAUTH2 $XOAUTH_STRING_RETRY") + output("3 OK Success") + requestCapabilities(tag = 4) + simplePostAuthenticationDialog(tag = 5) + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH XOAUTH2 with old token throws exception if retry fails`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2") + expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING") + output("+ r3j3krj3irj3oir3ojo") + expect("") + output("2 NO SASL authentication failed") + expect("3 AUTHENTICATE XOAUTH2 $XOAUTH_STRING_RETRY") + output("+ 433ba3a3a") + expect("") + output("3 NO SASL authentication failed") + postAuthenticationDialogRequestingCapabilities() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .prop(AuthenticationFailedException::messageFromServer) + .isEqualTo("SASL authentication failed") + } + + @Test + fun `open() AUTH XOAUTH2 parses capabilities`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2") + expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING") + output("2 OK [CAPABILITY IMAP4REV1 IDLE XM-GM-EXT-1]") + simplePostAuthenticationDialog(tag = 3) + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + assertThat(imapConnection.hasCapability("XM-GM-EXT-1")).isTrue() + } + + @Test + fun `open() AUTH EXTERNAL`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=EXTERNAL") + expect("2 AUTHENTICATE EXTERNAL ${USERNAME.base64()}") + output("2 OK Success") + postAuthenticationDialogRequestingCapabilities() + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.EXTERNAL) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH EXTERNAL with authentication failure should throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=EXTERNAL") + expect("2 AUTHENTICATE EXTERNAL ${USERNAME.base64()}") + output("2 NO Bad certificate") + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.EXTERNAL) + + // FIXME: improve exception message + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(CertificateValidationException::class) + .message().isNotNull().contains("Bad certificate") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() AUTH EXTERNAL without AUTH EXTERNAL capability should throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=PLAIN") + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.EXTERNAL) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(CertificateValidationException::class) + .prop(CertificateValidationException::getReason) + .isEqualTo(CertificateValidationException.Reason.MissingCapability) + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with no post-auth CAPABILITY response should issue CAPABILITY command`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=PLAIN") + expect("2 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output("2 OK Success") + expect("3 CAPABILITY") + output("* CAPABILITY IDLE") + output("3 OK CAPABILITY Completed") + simplePostAuthenticationDialog(tag = 4) + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + assertThat(imapConnection.isIdleCapable).isTrue() + } + + @Test + fun `open() with untagged post-auth CAPABILITY response`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=PLAIN") + expect("2 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output( + "* CAPABILITY IMAP4rev1 UNSELECT IDLE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS " + + "ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS LITERAL- SPECIAL-USE " + + "APPENDLIMIT=35651584" + ) + output("2 OK") + simplePostAuthenticationDialog(tag = 3) + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + assertThat(imapConnection.isIdleCapable).isTrue() + } + + @Test + fun `open() with post-auth CAPABILITY response`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "AUTH=PLAIN") + expect("2 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output("2 OK [CAPABILITY IDLE]") + simplePostAuthenticationDialog(tag = 3) + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + assertThat(imapConnection.isIdleCapable).isTrue() + } + + @Test + fun `open() with NAMESPACE capability should issue NAMESPACE command`() { + val server = MockImapServer().apply { + simplePreAuthAndLoginDialog(postAuthCapabilities = "NAMESPACE") + expect("3 NAMESPACE") + output("* NAMESPACE ((\"\" \"/\")) NIL NIL") + output("3 OK command completed") + } + val imapConnection = startServerAndCreateImapConnection(server) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test(expected = IOException::class) + fun `open() with connection error should throw`() { + val settings = createImapSettings(host = "127.1.2.3") + val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider) + + imapConnection.open() + } + + @Test + fun `open() with invalid hostname should throw`() { + val settings = createImapSettings(host = "host name") + val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(UnknownHostException::class) + + assertThat(imapConnection.isConnected).isFalse() + } + + @Test + fun `open() with STARTTLS capability should issue STARTTLS command`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "STARTTLS LOGINDISABLED") + expect("2 STARTTLS") + output("2 OK [CAPABILITY IMAP4REV1 NAMESPACE]") + startTls() + expect("3 CAPABILITY") + output("* CAPABILITY IMAP4 IMAP4REV1") + output("3 OK") + expect("4 LOGIN \"$USERNAME\" \"$PASSWORD\"") + output("4 OK [CAPABILITY NAMESPACE] LOGIN completed") + expect("5 NAMESPACE") + output("* NAMESPACE ((\"\" \"/\")) NIL NIL") + output("5 OK command completed") + } + val imapConnection = startServerAndCreateImapConnection( + server, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + authType = AuthType.PLAIN + ) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with STARTTLS but without STARTTLS capability should throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog() + } + val imapConnection = startServerAndCreateImapConnection( + server, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED + ) + + // FIXME: CertificateValidationException seems wrong + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(CertificateValidationException::class) + .hasMessage("STARTTLS connection security not available") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with untagged CAPABILITY after STARTTLS should not throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "STARTTLS LOGINDISABLED") + expect("2 STARTTLS") + output("2 OK Begin TLS negotiation now") + startTls() + output("* CAPABILITY IMAP4REV1 IMAP4") + expect("3 CAPABILITY") + output("* CAPABILITY IMAP4 IMAP4REV1") + output("3 OK") + expect("4 LOGIN \"$USERNAME\" \"$PASSWORD\"") + output("4 OK [CAPABILITY IMAP4REV1] LOGIN completed") + simplePostAuthenticationDialog(tag = 5) + } + val imapConnection = startServerAndCreateImapConnection( + server, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + authType = AuthType.PLAIN + ) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with negative response to STARTTLS command should throw`() { + val server = MockImapServer().apply { + preAuthenticationDialog(capabilities = "STARTTLS") + expect("2 STARTTLS") + output("2 NO") + } + val imapConnection = startServerAndCreateImapConnection( + server, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + authType = AuthType.PLAIN + ) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(NegativeImapResponseException::class) + .hasMessage("Command: STARTTLS; response: #2# [NO]") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with COMPRESS=DEFLATE capability should enable compression`() { + val server = MockImapServer().apply { + simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE") + expect("3 COMPRESS DEFLATE") + output("3 OK") + enableCompression() + simplePostAuthenticationDialog(tag = 4) + } + val imapConnection = startServerAndCreateImapConnection(server, useCompression = true) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with negative response to COMPRESS command should continue`() { + val server = MockImapServer().apply { + simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE") + expect("3 COMPRESS DEFLATE") + output("3 NO") + simplePostAuthenticationDialog(tag = 4) + } + val imapConnection = startServerAndCreateImapConnection(server, useCompression = true) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with IOException during COMPRESS command should throw`() { + val server = MockImapServer().apply { + simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE") + expect("3 COMPRESS DEFLATE") + closeConnection() + } + val imapConnection = startServerAndCreateImapConnection(server, useCompression = true) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(IOException::class) + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with IOException during LIST command should throw`() { + val server = MockImapServer().apply { + simplePreAuthAndLoginDialog() + expect("3 LIST \"\" \"\"") + output("* Now what?") + } + val imapConnection = startServerAndCreateImapConnection(server) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(IOException::class) + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with negative response to LIST command`() { + val server = MockImapServer().apply { + simplePreAuthAndLoginDialog() + expect("3 LIST \"\" \"\"") + output("3 NO") + } + val imapConnection = startServerAndCreateImapConnection(server) + + imapConnection.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `isConnected without previous open() should return false`() { + val settings = createImapSettings() + val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider) + + val result = imapConnection.isConnected + + assertThat(result).isFalse() + } + + @Test + fun `isConnected after open() should return true`() { + val server = MockImapServer() + val imapConnection = simpleOpen(server) + + val result = imapConnection.isConnected + + assertThat(result).isTrue() + server.verifyConnectionStillOpen() + server.shutdown() + } + + @Test + fun isConnected_afterOpenAndClose_shouldReturnFalse() { + val server = MockImapServer() + val imapConnection = simpleOpen(server) + imapConnection.close() + + val result = imapConnection.isConnected + + assertThat(result).isFalse() + server.verifyConnectionClosed() + server.shutdown() + } + + @Test + fun `close() without open() should not throw`() { + val settings = createImapSettings() + val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider) + + imapConnection.close() + } + + @Test + fun `close() after open() should close connection`() { + val server = MockImapServer() + val imapConnection = simpleOpen(server) + + imapConnection.close() + + server.verifyConnectionClosed() + server.shutdown() + } + + @Test + fun `isIdleCapable without IDLE capability should return false`() { + val server = MockImapServer() + val imapConnection = simpleOpen(server) + + val result = imapConnection.isIdleCapable + + assertThat(result).isFalse() + server.shutdown() + } + + @Test + fun `isIdleCapable with IDLE capability should return true`() { + val server = MockImapServer() + val imapConnection = simpleOpenWithCapabilities(server, postAuthCapabilities = "IDLE") + + val result = imapConnection.isIdleCapable + + assertThat(result).isTrue() + server.shutdown() + } + + @Test + fun `sendContinuation() should send line without tag`() { + val server = MockImapServer().apply { + simpleOpenDialog(postAuthCapabilities = "IDLE") + expect("4 IDLE") + output("+ idling") + expect("DONE") + } + val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + + imapConnection.open() + imapConnection.sendCommand("IDLE", false) + imapConnection.readResponse() + imapConnection.sendContinuation("DONE") + + server.waitForInteractionToComplete() + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `executeSimpleCommand() with OK response should return result`() { + val server = MockImapServer().apply { + simpleOpenDialog() + expect("4 CREATE Folder") + output("4 OK Folder created") + } + val imapConnection = startServerAndCreateImapConnection(server) + + val result = imapConnection.executeSimpleCommand("CREATE Folder") + + assertThat(result).hasSize(1) + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `executeSimpleCommand() with NO response should throw NegativeImapResponseException`() { + val server = MockImapServer().apply { + simpleOpenDialog() + expect("4 CREATE Folder") + output("4 NO Folder exists") + } + val imapConnection = startServerAndCreateImapConnection(server) + + assertThat { + imapConnection.executeSimpleCommand("CREATE Folder") + }.isFailure() + .isInstanceOf(NegativeImapResponseException::class) + .prop(NegativeImapResponseException::getLastResponse) + .containsExactly("NO", "Folder exists") + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `hasCapability() with not yet opened connection should connect and fetch capabilities`() { + val server = MockImapServer().apply { + simpleOpenDialog(postAuthCapabilities = "X-SOMETHING") + } + val imapConnection = startServerAndCreateImapConnection(server) + + val capabilityPresent = imapConnection.hasCapability("X-SOMETHING") + + assertThat(capabilityPresent).isTrue() + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `disconnect during LOGIN fallback should throw AuthenticationFailedException`() { + val server = MockImapServer().apply { + output("* OK example.org server") + expect("1 CAPABILITY") + output("* CAPABILITY IMAP4 IMAP4REV1 AUTH=PLAIN") + output("1 OK CAPABILITY Completed") + expect("2 AUTHENTICATE PLAIN") + output("+") + expect("\u0000$USERNAME\u0000$PASSWORD".base64()) + output("2 NO AUTHENTICATE failed") + expect("3 LOGIN \"$USERNAME\" \"$PASSWORD\"") + output("* BYE IMAP server terminating connection") + closeConnection() + } + val imapConnection = startServerAndCreateImapConnection(server) + + assertThat { + imapConnection.open() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .prop(AuthenticationFailedException::messageFromServer) + .isEqualTo("AUTHENTICATE failed") + + server.verifyInteractionCompleted() + } + + private fun createImapConnection( + settings: ImapSettings, + socketFactory: TrustedSocketFactory, + oAuth2TokenProvider: OAuth2TokenProvider + ): ImapConnection { + val connectionGeneration = 1 + return RealImapConnection( + settings, + socketFactory, + oAuth2TokenProvider, + connectionGeneration, + SOCKET_CONNECT_TIMEOUT, + SOCKET_READ_TIMEOUT + ) + } + + private fun startServerAndCreateImapConnection( + server: MockImapServer, + connectionSecurity: ConnectionSecurity = ConnectionSecurity.NONE, + authType: AuthType = AuthType.PLAIN, + useCompression: Boolean = false + ): ImapConnection { + server.start() + + val settings = SimpleImapSettings( + host = server.host, + port = server.port, + connectionSecurity = connectionSecurity, + authType = authType, + username = USERNAME, + password = PASSWORD, + useCompression = useCompression + ) + + return createImapConnection(settings, socketFactory, oAuth2TokenProvider) + } + + private fun simpleOpen(server: MockImapServer): ImapConnection { + return simpleOpenWithCapabilities(server, postAuthCapabilities = "") + } + + private fun simpleOpenWithCapabilities(server: MockImapServer, postAuthCapabilities: String): ImapConnection { + server.simpleOpenDialog(postAuthCapabilities) + + val imapConnection = startServerAndCreateImapConnection(server) + imapConnection.open() + + return imapConnection + } + + private fun MockImapServer.preAuthenticationDialog(capabilities: String = "") { + output("* OK IMAP4rev1 Service Ready") + expect("1 CAPABILITY") + output("* CAPABILITY IMAP4 IMAP4REV1 $capabilities") + output("1 OK CAPABILITY") + } + + private fun MockImapServer.postAuthenticationDialogRequestingCapabilities(tag: Int = 3) { + requestCapabilities(tag) + simplePostAuthenticationDialog(tag + 1) + } + + private fun MockImapServer.requestCapabilities(tag: Int) { + expect("$tag CAPABILITY") + output("* CAPABILITY IMAP4 IMAP4REV1 ") + output("$tag OK CAPABILITY") + } + + private fun MockImapServer.simplePostAuthenticationDialog(tag: Int) { + expect("$tag LIST \"\" \"\"") + output("* LIST () \"/\" foo/bar") + output("$tag OK") + } + + private fun MockImapServer.simpleOpenDialog(postAuthCapabilities: String = "") { + simplePreAuthAndLoginDialog(postAuthCapabilities) + simplePostAuthenticationDialog(3) + } + + private fun MockImapServer.simplePreAuthAndLoginDialog(postAuthCapabilities: String = "") { + preAuthenticationDialog() + expect("2 LOGIN \"$USERNAME\" \"$PASSWORD\"") + output("2 OK [CAPABILITY $postAuthCapabilities] LOGIN completed") + } + + private fun createImapSettings(host: String = "irrelevant"): ImapSettings { + return SimpleImapSettings( + host = host, + port = 143, + authType = AuthType.PLAIN, + username = "irrelevant" + ) + } +} + +class TestTokenProvider : OAuth2TokenProvider { + private var invalidationCount = 0 + + override fun getToken(timeoutMillis: Long): String { + assertThat(timeoutMillis).isEqualTo(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong()) + + return when (invalidationCount) { + 0 -> XOAUTH_TOKEN + 1 -> XOAUTH_TOKEN_2 + else -> { + throw AuthenticationFailedException( + "Ran out of auth tokens. invalidateToken() called too often?" + ) + } + } + } + + override fun invalidateToken() { + invalidationCount++ + } +} + +private fun String.base64() = this.encodeUtf8().base64() diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderIdlerTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderIdlerTest.kt new file mode 100644 index 0000000..98ff6f6 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderIdlerTest.kt @@ -0,0 +1,344 @@ +package com.fsck.k9.mail.store.imap + +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isFailure +import assertk.assertions.isFalse +import assertk.assertions.isGreaterThan +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import com.fsck.k9.mail.AuthenticationFailedException +import java.io.IOException +import java.net.SocketException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import org.junit.Test + +private const val FOLDER_SERVER_ID = "folder" +private const val TEST_TIMEOUT_SECONDS = 5L +private const val IDLE_TIMEOUT_MS = 28 * 60 * 1000L + +class RealImapFolderIdlerTest { + private val idleRefreshManager = TestIdleRefreshManager() + private val wakeLock = TestWakeLock(timeoutSeconds = TEST_TIMEOUT_SECONDS, isHeld = true) + private val imapConnection = TestImapConnection(timeout = TEST_TIMEOUT_SECONDS) + private val imapFolder = TestImapFolder(FOLDER_SERVER_ID, imapConnection) + private val imapStore = TestImapStore(imapFolder) + private val idleRefreshTimeoutProvider = object : IdleRefreshTimeoutProvider { + override val idleRefreshTimeoutMs = IDLE_TIMEOUT_MS + } + private val idler = RealImapFolderIdler( + idleRefreshManager, + wakeLock, + imapStore, + imapStore, + FOLDER_SERVER_ID, + idleRefreshTimeoutProvider + ) + + @Test + fun `new message during IDLE`() { + val latch = CountDownLatch(1) + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.SYNC) + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + imapConnection.enqueueContinuationServerResponse() + imapConnection.enqueueUntaggedServerResponse("1 EXISTS") + imapConnection.waitForCommand("DONE") + imapConnection.enqueueTaggedServerResponse("OK") + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `flag change during IDLE`() { + val latch = CountDownLatch(1) + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.SYNC) + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + imapConnection.enqueueContinuationServerResponse() + imapConnection.enqueueUntaggedServerResponse("42 FETCH (FLAGS (\\Seen))") + imapConnection.waitForCommand("DONE") + imapConnection.enqueueTaggedServerResponse("OK") + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `expunge during IDLE`() { + val latch = CountDownLatch(1) + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.SYNC) + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + imapConnection.enqueueContinuationServerResponse() + imapConnection.enqueueUntaggedServerResponse("23 EXPUNGE") + imapConnection.waitForCommand("DONE") + imapConnection.enqueueTaggedServerResponse("OK") + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `refresh IDLE connection`() { + val latch = CountDownLatch(1) + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.SYNC) + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + assertThat(wakeLock.isHeld).isTrue() + imapConnection.enqueueContinuationServerResponse() + wakeLock.waitForRelease() + idleRefreshManager.resetTimers() + imapConnection.waitForCommand("DONE") + imapConnection.enqueueTaggedServerResponse("OK") + + imapConnection.waitForCommand("IDLE") + assertThat(wakeLock.isHeld).isTrue() + imapConnection.enqueueContinuationServerResponse() + wakeLock.waitForRelease() + imapConnection.enqueueUntaggedServerResponse("1 EXISTS") + imapConnection.waitForCommand("DONE") + imapConnection.enqueueTaggedServerResponse("OK") + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + assertThat(wakeLock.isHeld).isTrue() + } + + @Test + fun `stop ImapFolderIdler while IDLE`() { + val latch = CountDownLatch(1) + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.STOPPED) + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + imapConnection.enqueueContinuationServerResponse() + wakeLock.waitForRelease() + idler.stop() + imapConnection.waitForCommand("DONE") + imapConnection.enqueueTaggedServerResponse("OK") + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `idle refresh timeout`() { + val latch = CountDownLatch(1) + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.STOPPED) + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + imapConnection.enqueueContinuationServerResponse() + wakeLock.waitForRelease() + assertThat(idleRefreshManager.getTimeoutValue()).isEqualTo(IDLE_TIMEOUT_MS) + idler.stop() + imapConnection.waitForCommand("DONE") + imapConnection.enqueueTaggedServerResponse("OK") + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `socket read timeouts`() { + val latch = CountDownLatch(1) + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.STOPPED) + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + imapConnection.enqueueContinuationServerResponse() + wakeLock.waitForRelease() + assertThat(imapConnection.currentSocketReadTimeout).isGreaterThan(IDLE_TIMEOUT_MS.toInt()) + idler.stop() + imapConnection.waitForCommand("DONE") + assertThat(imapConnection.currentSocketReadTimeout).isEqualTo(imapConnection.defaultSocketReadTimeout) + imapConnection.enqueueTaggedServerResponse("OK") + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `IDLE not supported`() { + val latch = CountDownLatch(1) + imapConnection.setIdleNotSupported() + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.NOT_SUPPORTED) + latch.countDown() + } + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `authentication error`() { + val latch = CountDownLatch(1) + imapFolder.throwOnOpen { throw AuthenticationFailedException("Authentication failure for test") } + + thread { + assertThat { + idler.idle() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .hasMessage("Authentication failure for test") + + latch.countDown() + } + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `network error on folder open`() { + val latch = CountDownLatch(1) + imapFolder.throwOnOpen { throw IOException("I/O error for test") } + + thread { + assertThat { + idler.idle() + }.isFailure() + .isInstanceOf(IOException::class) + .hasMessage("I/O error for test") + + latch.countDown() + } + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `network error on IDLE`() { + val latch = CountDownLatch(1) + + thread { + assertThat { + idler.idle() + }.isFailure() + .isInstanceOf(IOException::class) + .hasMessage("Socket closed during IDLE") + + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + imapConnection.enqueueContinuationServerResponse() + imapConnection.waitForBlockingRead() + imapConnection.throwOnRead { throw SocketException("Socket closed during IDLE") } + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `NO response to IDLE command`() { + val latch = CountDownLatch(1) + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.NOT_SUPPORTED) + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + imapConnection.enqueueTaggedServerResponse("NO") + + latch.awaitWithTimeout() + assertThat(imapFolder.isOpen).isFalse() + } + + @Test + fun `irrelevant untagged response to IDLE command before continuation request`() { + val latch = CountDownLatch(1) + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.STOPPED) + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + imapConnection.enqueueUntaggedServerResponse("OK irrelevant") + imapConnection.enqueueContinuationServerResponse() + + wakeLock.waitForRelease() + idler.stop() + imapConnection.waitForCommand("DONE") + imapConnection.enqueueTaggedServerResponse("OK") + latch.awaitWithTimeout() + } + + @Test + fun `relevant untagged response to IDLE command before continuation request`() { + val latch = CountDownLatch(1) + + thread { + val idleResult = idler.idle() + + assertThat(idleResult).isEqualTo(IdleResult.SYNC) + latch.countDown() + } + + imapConnection.waitForCommand("IDLE") + imapConnection.enqueueUntaggedServerResponse("1 EXISTS") + imapConnection.enqueueContinuationServerResponse() + imapConnection.waitForCommand("DONE") + imapConnection.enqueueTaggedServerResponse("OK") + latch.awaitWithTimeout() + } +} + +private fun CountDownLatch.awaitWithTimeout() { + assertThat(await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Test timed out").isTrue() +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt new file mode 100644 index 0000000..dad30e1 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt @@ -0,0 +1,1235 @@ +package com.fsck.k9.mail.store.imap + +import assertk.all +import assertk.assertThat +import assertk.assertions.cause +import assertk.assertions.containsExactly +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isFailure +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import com.fsck.k9.mail.Body +import com.fsck.k9.mail.DefaultBodyFactory +import com.fsck.k9.mail.FetchProfile +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.MessageRetrievalListener +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.Part +import com.fsck.k9.mail.internet.BinaryTempFileBody +import com.fsck.k9.mail.internet.MimeHeader +import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.util.Date +import java.util.TimeZone +import okio.Buffer +import org.apache.james.mime4j.util.MimeUtil +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anySet +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.startsWith +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class RealImapFolderTest { + private val internalImapStore = object : InternalImapStore { + override val logLabel = "Account" + override fun getCombinedPrefix() = "" + override fun getPermanentFlagsIndex() = mutableSetOf<Flag>() + } + private val imapConnection = mock<ImapConnection>() + private val testConnectionManager = TestConnectionManager(imapConnection) + + private lateinit var tempDirectory: File + + @Before + fun setUp() { + tempDirectory = Files.createTempDirectory("RealImapFolderTest").toFile() + BinaryTempFileBody.setTempDirectory(tempDirectory) + } + + @After + fun tearDown() { + tempDirectory.deleteRecursively() + } + + @Test + fun open_readWrite_shouldOpenFolder() { + val imapFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + + imapFolder.open(OpenMode.READ_WRITE) + + assertTrue(imapFolder.isOpen) + } + + @Test + fun open_readOnly_shouldOpenFolder() { + val imapFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + + imapFolder.open(OpenMode.READ_ONLY) + + assertTrue(imapFolder.isOpen) + } + + @Test + fun open_shouldFetchMessageCount() { + val imapFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + + imapFolder.open(OpenMode.READ_WRITE) + + assertEquals(23, imapFolder.messageCount) + } + + @Test + fun open_readWrite_shouldMakeGetModeReturnReadWrite() { + val imapFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + + imapFolder.open(OpenMode.READ_WRITE) + + assertEquals(OpenMode.READ_WRITE, imapFolder.mode) + } + + @Test + fun open_readOnly_shouldMakeGetModeReturnReadOnly() { + val imapFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + + imapFolder.open(OpenMode.READ_ONLY) + + assertEquals(OpenMode.READ_ONLY, imapFolder.mode) + } + + @Test + fun open_shouldMakeExistReturnTrueWithoutExecutingAdditionalCommands() { + val imapFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + + imapFolder.open(OpenMode.READ_WRITE) + + assertTrue(imapFolder.exists()) + verify(imapConnection, times(1)).executeSimpleCommand(anyString()) + } + + @Test + fun open_calledTwice_shouldReuseSameImapConnection() { + val imapFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + imapFolder.open(OpenMode.READ_WRITE) + + imapFolder.open(OpenMode.READ_WRITE) + + assertThat(testConnectionManager.numberOfGetConnectionCalls).isEqualTo(1) + } + + @Test + fun open_withConnectionThrowingOnReUse_shouldCreateNewImapConnection() { + val imapFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + imapFolder.open(OpenMode.READ_WRITE) + + doThrow(IOException::class).whenever(imapConnection).executeSimpleCommand(Commands.NOOP) + imapFolder.open(OpenMode.READ_WRITE) + + assertThat(testConnectionManager.numberOfGetConnectionCalls).isEqualTo(2) + } + + @Test + fun open_withIoException_shouldThrowMessagingException() { + val imapFolder = createFolder("Folder") + doThrow(IOException::class).whenever(imapConnection).executeSimpleCommand("SELECT \"Folder\"") + + assertThat { + imapFolder.open(OpenMode.READ_WRITE) + }.isFailure() + .isInstanceOf(MessagingException::class).cause().isNotNull().isInstanceOf(IOException::class) + } + + @Test + fun open_withMessagingException_shouldThrowMessagingException() { + val imapFolder = createFolder("Folder") + doThrow(MessagingException::class).whenever(imapConnection).executeSimpleCommand("SELECT \"Folder\"") + + assertThat { + imapFolder.open(OpenMode.READ_WRITE) + }.isFailure() + .isInstanceOf(MessagingException::class) + } + + @Test + fun open_withoutExistsResponse_shouldThrowMessagingException() { + val imapFolder = createFolder("Folder") + val selectResponses = listOf( + createImapResponse("* OK [UIDNEXT 57576] Predicted next UID"), + createImapResponse("2 OK [READ-WRITE] Select completed.") + ) + whenever(imapConnection.executeSimpleCommand("SELECT \"Folder\"")).thenReturn(selectResponses) + + assertThat { + imapFolder.open(OpenMode.READ_WRITE) + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Did not find message count during open") + } + + @Test + fun close_shouldCloseImapFolder() { + val imapFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + imapFolder.open(OpenMode.READ_WRITE) + + imapFolder.close() + + assertFalse(imapFolder.isOpen) + } + + @Test + fun exists_withClosedFolder_shouldOpenConnectionAndIssueStatusCommand() { + val imapFolder = createFolder("Folder") + + imapFolder.exists() + + verify(imapConnection).executeSimpleCommand("STATUS \"Folder\" (UIDVALIDITY)") + } + + @Test + fun exists_withoutNegativeImapResponse_shouldReturnTrue() { + val imapFolder = createFolder("Folder") + + val folderExists = imapFolder.exists() + + assertTrue(folderExists) + } + + @Test + fun exists_withNegativeImapResponse_shouldReturnFalse() { + val imapFolder = createFolder("Folder") + doThrow(NegativeImapResponseException::class) + .whenever(imapConnection).executeSimpleCommand("STATUS \"Folder\" (UIDVALIDITY)") + + val folderExists = imapFolder.exists() + + assertFalse(folderExists) + } + + @Test + fun create_withClosedFolder_shouldOpenConnectionAndIssueCreateCommand() { + val imapFolder = createFolder("Folder") + + imapFolder.create() + + verify(imapConnection).executeSimpleCommand("CREATE \"Folder\"") + } + + @Test + fun create_withoutNegativeImapResponse_shouldReturnTrue() { + val imapFolder = createFolder("Folder") + + val success = imapFolder.create() + + assertTrue(success) + } + + @Test + fun create_withNegativeImapResponse_shouldReturnFalse() { + val imapFolder = createFolder("Folder") + doThrow(NegativeImapResponseException::class).whenever(imapConnection).executeSimpleCommand("CREATE \"Folder\"") + + val success = imapFolder.create() + + assertFalse(success) + } + + @Test + fun copyMessages_withEmptyMessageList_shouldReturnNull() { + val sourceFolder = createFolder("Source") + val destinationFolder = createFolder("Destination") + val messages = emptyList<ImapMessage>() + + val uidMapping = sourceFolder.copyMessages(messages, destinationFolder) + + assertNull(uidMapping) + } + + @Test + fun copyMessages_withClosedFolder_shouldThrow() { + val sourceFolder = createFolder("Source") + val destinationFolder = createFolder("Destination") + val messages = listOf(mock<ImapMessage>()) + + assertThat { + sourceFolder.copyMessages(messages, destinationFolder) + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Folder Source is not open.") + } + + @Test + fun copyMessages() { + val sourceFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + val destinationFolder = createFolder("Destination") + val messages = listOf(createImapMessage("1")) + setupCopyResponse("x OK [COPYUID 23 1 101] Success") + sourceFolder.open(OpenMode.READ_WRITE) + + val uidMapping = sourceFolder.copyMessages(messages, destinationFolder) + + assertNotNull(uidMapping) + assertEquals("101", uidMapping!!["1"]) + } + + @Test + fun moveMessages_shouldCopyMessages() { + val sourceFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + val destinationFolder = createFolder("Destination") + val messages = listOf(createImapMessage("1")) + setupCopyResponse("x OK [COPYUID 23 1 101] Success") + sourceFolder.open(OpenMode.READ_WRITE) + + val uidMapping = sourceFolder.moveMessages(messages, destinationFolder) + + assertNotNull(uidMapping) + assertEquals("101", uidMapping!!["1"]) + } + + @Test + fun moveMessages_shouldDeleteMessagesFromSourceFolder() { + val sourceFolder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + val destinationFolder = createFolder("Destination") + val messages = listOf(createImapMessage("1")) + sourceFolder.open(OpenMode.READ_WRITE) + + sourceFolder.moveMessages(messages, destinationFolder) + + assertCommandWithIdsIssued("UID STORE 1 +FLAGS.SILENT (\\Deleted)") + } + + @Test + fun moveMessages_withEmptyMessageList_shouldReturnNull() { + val sourceFolder = createFolder("Source") + val destinationFolder = createFolder("Destination") + val messages = emptyList<ImapMessage>() + + val uidMapping = sourceFolder.moveMessages(messages, destinationFolder) + + assertNull(uidMapping) + } + + @Test + fun getUnreadMessageCount_withClosedFolder_shouldThrow() { + val folder = createFolder("FolderName") + + assertThat { + folder.unreadMessageCount + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Folder FolderName is not open.") + } + + @Test + fun getUnreadMessageCount_connectionThrowsIOException_shouldThrowMessagingException() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + whenever(imapConnection.executeSimpleCommand("SEARCH 1:* UNSEEN NOT DELETED")).thenThrow(IOException()) + folder.open(OpenMode.READ_WRITE) + + assertThat { + folder.unreadMessageCount + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("IO Error") + } + + @Test + fun getUnreadMessageCount() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + val imapResponses = listOf(createImapResponse("* SEARCH 1 2 3")) + whenever(imapConnection.executeSimpleCommand("SEARCH 1:* UNSEEN NOT DELETED")).thenReturn(imapResponses) + folder.open(OpenMode.READ_WRITE) + + val unreadMessageCount = folder.unreadMessageCount + + assertEquals(3, unreadMessageCount) + } + + @Test + fun getFlaggedMessageCount_withClosedFolder_shouldThrow() { + val folder = createFolder("FolderName") + + assertThat { + folder.flaggedMessageCount + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Folder FolderName is not open.") + } + + @Test + fun getFlaggedMessageCount() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + val imapResponses = listOf( + createImapResponse("* SEARCH 1 2"), + createImapResponse("* SEARCH 23 42") + ) + whenever(imapConnection.executeSimpleCommand("SEARCH 1:* FLAGGED NOT DELETED")).thenReturn(imapResponses) + folder.open(OpenMode.READ_WRITE) + + val flaggedMessageCount = folder.flaggedMessageCount + + assertEquals(4, flaggedMessageCount) + } + + @Test + fun getHighestUid() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + setupUidSearchResponses("* SEARCH 42") + folder.open(OpenMode.READ_WRITE) + + val highestUid = folder.highestUid + + assertEquals(42L, highestUid) + } + + @Test + fun getHighestUid_imapConnectionThrowsNegativesResponse_shouldReturnMinusOne() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + doThrow(NegativeImapResponseException::class).whenever(imapConnection).executeSimpleCommand("UID SEARCH *:*") + folder.open(OpenMode.READ_WRITE) + + val highestUid = folder.highestUid + + assertEquals(-1L, highestUid) + } + + @Test + fun getHighestUid_imapConnectionThrowsIOException_shouldThrowMessagingException() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + doThrow(IOException::class).whenever(imapConnection).executeSimpleCommand("UID SEARCH *:*") + folder.open(OpenMode.READ_WRITE) + + assertThat { + folder.highestUid + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("IO Error") + } + + @Test + fun getMessages_withoutDateConstraint() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + setupUidSearchResponses("* SEARCH 3", "* SEARCH 5", "* SEARCH 6") + folder.open(OpenMode.READ_WRITE) + + val messages = folder.getMessages(1, 10, null, null) + + assertNotNull(messages) + assertEquals(setOf("3", "5", "6"), extractMessageUids(messages)) + } + + @Test + fun getMessages_withDateConstraint() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + setupUidSearchResponses("* SEARCH 47", "* SEARCH 18") + folder.open(OpenMode.READ_WRITE) + + val messages = folder.getMessages(1, 10, Date(1454719826000L), null) + + assertNotNull(messages) + assertEquals(setOf("18", "47"), extractMessageUids(messages)) + } + + @Test + fun getMessages_withListener_shouldCallListener() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + setupUidSearchResponses("* SEARCH 99") + folder.open(OpenMode.READ_WRITE) + val listener = createMessageRetrievalListener() + + val messages = folder.getMessages(1, 10, null, listener) + + verify(listener).messageFinished(messages[0]) + verifyNoMoreInteractions(listener) + } + + @Test + fun getMessages_withInvalidStartArgument_shouldThrow() { + val folder = createFolder("Folder") + + assertThat { + folder.getMessages(0, 10, null, null) + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Invalid message set 0 10") + } + + @Test + fun getMessages_withInvalidEndArgument_shouldThrow() { + val folder = createFolder("Folder") + + assertThat { + folder.getMessages(10, 0, null, null) + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Invalid message set 10 0") + } + + @Test + fun getMessages_withEndArgumentSmallerThanStartArgument_shouldThrow() { + val folder = createFolder("Folder") + + assertThat { + folder.getMessages(10, 5, null, null) + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Invalid message set 10 5") + } + + @Test + fun getMessages_withClosedFolder_shouldThrow() { + val folder = createFolder("FolderName") + + assertThat { + folder.getMessages(1, 5, null, null) + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Folder FolderName is not open.") + } + + @Test + fun getMessages_sequenceNumbers_withClosedFolder_shouldThrow() { + val folder = createFolder("FolderName") + + assertThat { + folder.getMessages(setOf(1L, 2L, 5L), false, null) + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Folder FolderName is not open.") + } + + @Test + fun getMessages_sequenceNumbers() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + setupUidSearchResponses("* SEARCH 17", "* SEARCH 18", "* SEARCH 49") + folder.open(OpenMode.READ_WRITE) + + val messages = folder.getMessages(setOf(1L, 2L, 5L), false, null) + + assertNotNull(messages) + assertEquals(setOf("17", "18", "49"), extractMessageUids(messages)) + } + + @Test + fun getMessages_sequenceNumbers_withListener_shouldCallListener() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + setupUidSearchResponses("* SEARCH 99") + folder.open(OpenMode.READ_WRITE) + val listener = createMessageRetrievalListener() + + val messages = folder.getMessages(setOf(1L), true, listener) + + verify(listener).messageFinished(messages[0]) + verifyNoMoreInteractions(listener) + } + + @Test + fun getMessagesFromUids_withClosedFolder_shouldThrow() { + val folder = createFolder("FolderName") + + assertThat { + folder.getMessagesFromUids(listOf("11", "22", "25")) + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Folder FolderName is not open.") + } + + @Test + fun getMessagesFromUids() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + setupUidSearchResponses("* SEARCH 11", "* SEARCH 22", "* SEARCH 25") + folder.open(OpenMode.READ_WRITE) + + val messages = folder.getMessagesFromUids(listOf("11", "22", "25")) + + assertNotNull(messages) + assertEquals(setOf("11", "22", "25"), extractMessageUids(messages)) + } + + @Test + fun areMoreMessagesAvailable_withClosedFolder_shouldThrow() { + val folder = createFolder("FolderName") + + assertThat { + folder.areMoreMessagesAvailable(10, Date()) + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Folder FolderName is not open.") + } + + @Test + fun areMoreMessagesAvailable_withAdditionalMessages_shouldReturnTrue() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + setupSearchResponses("* SEARCH 42") + folder.open(OpenMode.READ_WRITE) + + val areMoreMessagesAvailable = folder.areMoreMessagesAvailable(10, null) + + assertTrue(areMoreMessagesAvailable) + } + + @Test + fun areMoreMessagesAvailable_withoutAdditionalMessages_shouldReturnFalse() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + setupSearchResponses("1 OK SEARCH completed") + folder.open(OpenMode.READ_WRITE) + + val areMoreMessagesAvailable = folder.areMoreMessagesAvailable(600, null) + + assertFalse(areMoreMessagesAvailable) + } + + @Test + fun areMoreMessagesAvailable_withIndexOfOne_shouldReturnFalseWithoutPerformingSearch() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + folder.open(OpenMode.READ_WRITE) + + val areMoreMessagesAvailable = folder.areMoreMessagesAvailable(1, null) + + assertFalse(areMoreMessagesAvailable) + // SELECT during OPEN and no more + verify(imapConnection, times(1)).executeSimpleCommand(anyString()) + } + + @Test + fun areMoreMessagesAvailable_withoutAdditionalMessages_shouldIssueSearchCommandsUntilAllMessagesSearched() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + setupSearchResponses("1 OK SEARCH Completed") + folder.open(OpenMode.READ_WRITE) + + folder.areMoreMessagesAvailable(600, null) + + assertCommandIssued("SEARCH 100:599 NOT DELETED") + assertCommandIssued("SEARCH 1:99 NOT DELETED") + } + + @Test + fun fetch_withEmptyMessageListArgument_shouldDoNothing() { + val folder = createFolder("Folder") + val fetchProfile = createFetchProfile() + + folder.fetch(emptyList(), fetchProfile, null, MAX_DOWNLOAD_SIZE) + + assertThat(testConnectionManager.numberOfGetConnectionCalls).isEqualTo(0) + } + + @Test + fun fetch_withFlagsFetchProfile_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK")) + val messages = createImapMessages("1") + val fetchProfile = createFetchProfile(FetchProfile.Item.FLAGS) + + folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE) + + verify(imapConnection).sendCommand("UID FETCH 1 (UID FLAGS)", false) + } + + @Test + fun fetch_withEnvelopeFetchProfile_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK")) + val messages = createImapMessages("1") + val fetchProfile = createFetchProfile(FetchProfile.Item.ENVELOPE) + + folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE) + + verify(imapConnection).sendCommand( + "UID FETCH 1 (UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS " + + "(date subject from content-type to cc bcc reply-to message-id references in-reply-to " + + "list-unsubscribe sender X-K9mail-Identity Chat-Version)])", + false + ) + } + + @Test + fun fetch_withStructureFetchProfile_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK")) + val messages = createImapMessages("1") + val fetchProfile = createFetchProfile(FetchProfile.Item.STRUCTURE) + + folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE) + + verify(imapConnection).sendCommand("UID FETCH 1 (UID BODYSTRUCTURE)", false) + } + + @Test + fun fetch_withStructureFetchProfile_shouldSetContentType() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + val bodyStructure = "(\"TEXT\" \"PLAIN\" (\"CHARSET\" \"US-ASCII\") NIL NIL \"7BIT\" 2279 48)" + whenever(imapConnection.readResponse(anyOrNull())) + .thenReturn(createImapResponse("* 1 FETCH (BODYSTRUCTURE $bodyStructure UID 1)")) + .thenReturn(createImapResponse("x OK")) + val messages = createImapMessages("1") + val fetchProfile = createFetchProfile(FetchProfile.Item.STRUCTURE) + + folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE) + + verify(messages[0]).setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain;\r\n CHARSET=US-ASCII") + } + + @Test + fun `fetch() with simple content type parameter`() { + testHeaderFromBodyStructure( + bodyStructure = """("text" "plain" ("name" "token") NIL NIL "7bit" 42 23)""", + headerName = MimeHeader.HEADER_CONTENT_TYPE, + expectedHeaderValue = "text/plain;\r\n name=token" + ) + } + + @Test + fun `fetch() with content type parameter that needs to be a quoted string`() { + testHeaderFromBodyStructure( + bodyStructure = """("text" "plain" ("name" "one two three") NIL NIL "7bit" 42 23)""", + headerName = MimeHeader.HEADER_CONTENT_TYPE, + expectedHeaderValue = "text/plain;\r\n name=\"one two three\"" + ) + } + + @Test + fun `fetch() with content type parameter that needs to be a quoted string with escaped characters`() { + testHeaderFromBodyStructure( + bodyStructure = """("text" "plain" ("name" "one \"two\" three") NIL NIL "7bit" 42 23)""", + headerName = MimeHeader.HEADER_CONTENT_TYPE, + expectedHeaderValue = "text/plain;\r\n name=\"one \\\"two\\\" three\"" + ) + } + + @Test + fun `fetch() with RFC 2231 encoded content type parameter`() { + testHeaderFromBodyStructure( + bodyStructure = """("text" "plain" ("name*" "utf-8''filen%C3%A4me.ext") NIL NIL "7bit" 42 23)""", + headerName = MimeHeader.HEADER_CONTENT_TYPE, + expectedHeaderValue = "text/plain;\r\n name*=utf-8''filen%C3%A4me.ext" + ) + } + + @Test + fun `fetch() with UTF-8 encoded content type parameter`() { + testHeaderFromBodyStructure( + bodyStructure = """("text" "plain" ("name" "filenäme.ext") NIL NIL "7bit" 42 23)""", + headerName = MimeHeader.HEADER_CONTENT_TYPE, + expectedHeaderValue = "text/plain;\r\n name=\"filenäme.ext\"" + ) + } + + @Test + fun `fetch() with simple content disposition parameter`() { + testHeaderFromBodyStructure( + bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ + + """("attachment" ("filename" "token")) NIL NIL)""", + headerName = MimeHeader.HEADER_CONTENT_DISPOSITION, + expectedHeaderValue = "attachment;\r\n filename=token;\r\n size=23" + ) + } + + @Test + fun `fetch() with content disposition parameter that needs to be a quoted string`() { + testHeaderFromBodyStructure( + bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ + + """("attachment" ("filename" "one two three")) NIL NIL)""", + headerName = MimeHeader.HEADER_CONTENT_DISPOSITION, + expectedHeaderValue = "attachment;\r\n filename=\"one two three\";\r\n size=23" + ) + } + + @Test + fun `fetch() with content disposition parameter that needs to be a quoted string with escaped characters`() { + testHeaderFromBodyStructure( + bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ + + """("attachment" ("filename" "one \"two\" three")) NIL NIL)""", + headerName = MimeHeader.HEADER_CONTENT_DISPOSITION, + expectedHeaderValue = "attachment;\r\n filename=\"one \\\"two\\\" three\";\r\n size=23" + ) + } + + @Test + fun `fetch() with RFC 2231 encoded content disposition parameter`() { + testHeaderFromBodyStructure( + bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ + + """("attachment" ("filename*" "utf-8''filen%C3%A4me.ext")) NIL NIL)""", + headerName = MimeHeader.HEADER_CONTENT_DISPOSITION, + expectedHeaderValue = "attachment;\r\n filename*=utf-8''filen%C3%A4me.ext;\r\n size=23" + ) + } + + @Test + fun `fetch() with UTF-8 encoded content disposition parameter`() { + testHeaderFromBodyStructure( + bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ + + """("attachment" ("filename" "filenäme.ext")) NIL NIL)""", + headerName = MimeHeader.HEADER_CONTENT_DISPOSITION, + expectedHeaderValue = "attachment;\r\n filename=\"filenäme.ext\";\r\n size=23" + ) + } + + @Test + fun fetch_withBodySaneFetchProfile_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK")) + val messages = createImapMessages("1") + val fetchProfile = createFetchProfile(FetchProfile.Item.BODY_SANE) + + folder.fetch(messages, fetchProfile, null, 4096) + + verify(imapConnection).sendCommand("UID FETCH 1 (UID BODY.PEEK[]<0.4096>)", false) + } + + @Test + fun fetch_withBodySaneFetchProfileAndNoMaximumDownloadSize_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK")) + val messages = createImapMessages("1") + val fetchProfile = createFetchProfile(FetchProfile.Item.BODY_SANE) + + folder.fetch(messages, fetchProfile, null, 0) + + verify(imapConnection).sendCommand("UID FETCH 1 (UID BODY.PEEK[])", false) + } + + @Test + fun fetch_withBodyFetchProfileAndNoMaximumDownloadSize_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK")) + val messages = createImapMessages("1") + val fetchProfile = createFetchProfile(FetchProfile.Item.BODY) + + folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE) + + verify(imapConnection).sendCommand("UID FETCH 1 (UID BODY.PEEK[])", false) + } + + @Test + fun fetch_withFlagsFetchProfile_shouldSetFlags() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + val messages = createImapMessages("1") + val fetchProfile = createFetchProfile(FetchProfile.Item.FLAGS) + whenever(imapConnection.readResponse(anyOrNull())) + .thenReturn(createImapResponse("* 1 FETCH (FLAGS (\\Seen) UID 1)")) + .thenReturn(createImapResponse("x OK")) + + folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE) + + verify(messages[0]).setFlag(Flag.SEEN, true) + } + + @Test + fun fetchPart_withTextSection_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + val message = createImapMessage("1") + val part = createPart("TEXT") + whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK")) + + folder.fetchPart(message, part, mock(), 4096) + + verify(imapConnection).sendCommand("UID FETCH 1 (UID BODY.PEEK[TEXT]<0.4096>)", false) + } + + @Test + fun fetchPart_withNonTextSection_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + val message = createImapMessage("1") + val part = createPart("1.1") + whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK")) + + folder.fetchPart(message, part, mock(), MAX_DOWNLOAD_SIZE) + + verify(imapConnection).sendCommand("UID FETCH 1 (UID BODY.PEEK[1.1])", false) + } + + @Test + fun fetchPart_withTextSection_shouldProcessImapResponses() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + val message = createImapMessage("1") + val part = createPlainTextPart("1.1") + setupSingleFetchResponseToCallback() + + folder.fetchPart(message, part, DefaultBodyFactory(), MAX_DOWNLOAD_SIZE) + + val bodyArgumentCaptor = argumentCaptor<Body>() + verify(part).body = bodyArgumentCaptor.capture() + val body = bodyArgumentCaptor.firstValue + val buffer = Buffer() + body.writeTo(buffer.outputStream()) + assertEquals("text", buffer.readUtf8()) + } + + @Test + fun appendMessages_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + folder.open(OpenMode.READ_WRITE) + val messages = listOf(createImapMessage("1")) + whenever(imapConnection.readResponse()).thenReturn(createImapResponse("x OK [APPENDUID 1 23]")) + + folder.appendMessages(messages) + + verify(imapConnection).sendCommand("APPEND \"Folder\" () {0}", false) + } + + @Test + fun appendMessages_withNegativeResponse_shouldThrow() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + folder.open(OpenMode.READ_WRITE) + val messages = listOf(createImapMessage("1")) + whenever(imapConnection.readResponse()).thenReturn(createImapResponse("x NO Can't append to this folder")) + + assertThat { + folder.appendMessages(messages) + }.isFailure() + .isInstanceOf(NegativeImapResponseException::class.java).all { + hasMessage("APPEND failed") + transform { it.lastResponse[0] }.isEqualTo("NO") + } + } + + @Test + fun appendMessages_withBadResponse_shouldThrow() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + folder.open(OpenMode.READ_WRITE) + val messages = listOf(createImapMessage("1")) + whenever(imapConnection.readResponse()).thenReturn(createImapResponse("x BAD [TOOBIG] Message too large.")) + + assertThat { + folder.appendMessages(messages) + }.isFailure() + .isInstanceOf(NegativeImapResponseException::class.java).all { + hasMessage("APPEND failed") + transform { it.lastResponse[0] }.isEqualTo("BAD") + } + } + + @Test + fun getUidFromMessageId_withMessageIdHeader_shouldIssueUidSearchCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + folder.open(OpenMode.READ_WRITE) + setupUidSearchResponses("1 OK SEARCH Completed") + + folder.getUidFromMessageId("<00000000.0000000@example.org>") + + assertCommandIssued("UID SEARCH HEADER MESSAGE-ID \"<00000000.0000000@example.org>\"") + } + + @Test + fun getUidFromMessageId() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + folder.open(OpenMode.READ_WRITE) + setupUidSearchResponses("* SEARCH 23") + + val uid = folder.getUidFromMessageId("<00000000.0000000@example.org>") + + assertEquals("23", uid) + } + + @Test + fun expunge_shouldIssueExpungeCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + + folder.expunge() + + verify(imapConnection).executeSimpleCommand("EXPUNGE") + } + + @Test + fun expungeUids_withUidPlus_shouldIssueUidExpungeCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + whenever(imapConnection.isUidPlusCapable).thenReturn(true) + + folder.expungeUids(listOf("1")) + + assertCommandWithIdsIssued("UID EXPUNGE 1") + } + + @Test + fun expungeUids_withoutUidPlus_shouldIssueExpungeCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + whenever(imapConnection.isUidPlusCapable).thenReturn(false) + + folder.expungeUids(listOf("1")) + + verify(imapConnection).executeSimpleCommand("EXPUNGE") + } + + @Test + fun setFlags_shouldIssueUidStoreCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_WRITE) + + folder.setFlags(setOf(Flag.SEEN), true) + + assertCommandIssued("UID STORE 1:* +FLAGS.SILENT (\\Seen)") + } + + @Test + fun search_withFullTextSearchEnabled_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + setupUidSearchResponses("1 OK SEARCH completed") + + folder.search("query", setOf(Flag.SEEN), emptySet(), true) + + assertCommandIssued("UID SEARCH TEXT \"query\" SEEN") + } + + @Test + fun search_withFullTextSearchDisabled_shouldIssueRespectiveCommand() { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + setupUidSearchResponses("1 OK SEARCH completed") + + folder.search("query", emptySet(), emptySet(), false) + + assertCommandIssued("""UID SEARCH OR OR OR OR SUBJECT "query" FROM "query" TO "query" CC "query" BCC "query"""") + } + + @Test + fun getMessageByUid_returnsNewImapMessageWithUid() { + val folder = createFolder("Folder") + + val message = folder.getMessage("uid") + + assertEquals("uid", message.uid) + } + + @Suppress("SameParameterValue") + private fun createPlainTextPart(serverExtra: String): Part { + val part = createPart(serverExtra) + whenever(part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING)).thenReturn(arrayOf(MimeUtil.ENC_7BIT)) + whenever(part.getHeader(MimeHeader.HEADER_CONTENT_TYPE)).thenReturn(arrayOf("text/plain")) + return part + } + + private fun setupSingleFetchResponseToCallback() { + whenever(imapConnection.readResponse(anyOrNull())) + .thenAnswer { invocation -> + val callback = invocation.arguments[0] as ImapResponseCallback + buildImapFetchResponse(callback) + } + .thenAnswer { invocation -> + val callback = invocation.arguments[0] as ImapResponseCallback + ImapResponse.newTaggedResponse(callback, "TAG") + } + } + + private fun buildImapFetchResponse(callback: ImapResponseCallback): ImapResponse { + val response = ImapResponse.newContinuationRequest(callback) + response.add("1") + response.add("FETCH") + val fetchList = ImapList() + fetchList.add("UID") + fetchList.add("1") + fetchList.add("BODY") + fetchList.add("1.1") + fetchList.add("text") + response.add(fetchList) + return response + } + + private fun extractMessageUids(messages: List<ImapMessage>) = messages.map { it.uid }.toSet() + + private fun createFolder(folderName: String): RealImapFolder { + return RealImapFolder(internalImapStore, testConnectionManager, folderName, FolderNameCodec()) + } + + private fun createImapMessage(uid: String): ImapMessage { + return mock { + on { this.uid } doReturn uid + } + } + + private fun createImapMessages(vararg uids: String) = uids.map { createImapMessage(it) } + + private fun createPart(serverExtra: String): Part { + return mock { + on { this.serverExtra } doReturn serverExtra + } + } + + private fun createFetchProfile(vararg items: FetchProfile.Item) = items.toCollection(FetchProfile()) + + private fun createMessageRetrievalListener() = mock<MessageRetrievalListener<ImapMessage>>() + + private fun prepareImapFolderForOpen(openMode: OpenMode) { + val imapResponses = listOf( + createImapResponse("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft NonJunk \$MDNSent)"), + createImapResponse( + "* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft NonJunk \$MDNSent \\*)] " + + "Flags permitted." + ), + createImapResponse("* 23 EXISTS"), + createImapResponse("* 0 RECENT"), + createImapResponse("* OK [UIDVALIDITY 1125022061] UIDs valid"), + createImapResponse("* OK [UIDNEXT 57576] Predicted next UID"), + if (openMode == OpenMode.READ_WRITE) { + createImapResponse("2 OK [READ-WRITE] Select completed.") + } else { + createImapResponse("2 OK [READ-ONLY] Examine completed.") + } + ) + + if (openMode == OpenMode.READ_WRITE) { + whenever(imapConnection.executeSimpleCommand("SELECT \"Folder\"")).thenReturn(imapResponses) + } else { + whenever(imapConnection.executeSimpleCommand("EXAMINE \"Folder\"")).thenReturn(imapResponses) + } + } + + private fun assertCommandWithIdsIssued(expectedCommand: String) { + val commandPrefixCaptor = argumentCaptor<String>() + val commandSuffixCaptor = argumentCaptor<String>() + val commandUidsCaptor = argumentCaptor<Set<Long>>() + verify(imapConnection, atLeastOnce()).executeCommandWithIdSet( + commandPrefixCaptor.capture(), + commandSuffixCaptor.capture(), + commandUidsCaptor.capture() + ) + + val commandPrefixes = commandPrefixCaptor.allValues + val commandSuffixes = commandSuffixCaptor.allValues + val commandUids = commandUidsCaptor.allValues + + for (i in commandPrefixes.indices) { + val command = commandPrefixes[i] + " " + ImapUtility.join(",", commandUids[i]) + + if (commandSuffixes[i].isEmpty()) "" else " " + commandSuffixes[i] + + if (command == expectedCommand) { + return + } + } + + fail("Expected IMAP command not issued: $expectedCommand") + } + + private fun assertCommandIssued(expectedCommand: String) { + verify(imapConnection, atLeastOnce()).executeSimpleCommand(expectedCommand) + } + + private fun setupUidSearchResponses(vararg responses: String) { + val imapResponses = responses.map { createImapResponse(it) } + whenever(imapConnection.executeSimpleCommand(startsWith("UID SEARCH"))).thenReturn(imapResponses) + whenever(imapConnection.executeCommandWithIdSet(startsWith("UID SEARCH"), anyString(), anySet())) + .thenReturn(imapResponses) + } + + private fun setupSearchResponses(vararg responses: String) { + val imapResponses = responses.map { createImapResponse(it) } + whenever(imapConnection.executeSimpleCommand(startsWith("SEARCH"))).thenReturn(imapResponses) + } + + @Suppress("SameParameterValue") + private fun setupCopyResponse(response: String) { + val imapResponses = listOf(createImapResponse(response)) + whenever(imapConnection.executeCommandWithIdSet(eq(Commands.UID_COPY), anyString(), anySet())) + .thenReturn(imapResponses) + } + + private fun testHeaderFromBodyStructure(bodyStructure: String, headerName: String, expectedHeaderValue: String) { + val folder = createFolder("Folder") + prepareImapFolderForOpen(OpenMode.READ_ONLY) + folder.open(OpenMode.READ_ONLY) + whenever(imapConnection.readResponse(anyOrNull())) + .thenReturn(createImapResponse("* 1 FETCH (BODYSTRUCTURE $bodyStructure UID 1)")) + .thenReturn(createImapResponse("x OK")) + val imapMessage = ImapMessage("1") + val messages = listOf(imapMessage) + val fetchProfile = createFetchProfile(FetchProfile.Item.STRUCTURE) + + folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE) + + assertThat(imapMessage.getHeader(headerName)).containsExactly(expectedHeaderValue) + } + + companion object { + private const val MAX_DOWNLOAD_SIZE = -1 + } +} + +internal class TestConnectionManager(private val connection: ImapConnection) : ImapConnectionManager { + var numberOfGetConnectionCalls = 0 + private set + + override fun getConnection(): ImapConnection { + numberOfGetConnectionCalls++ + return connection + } + + override fun releaseConnection(connection: ImapConnection?) = Unit +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt new file mode 100644 index 0000000..5c64b49 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt @@ -0,0 +1,446 @@ +package com.fsck.k9.mail.store.imap + +import assertk.all +import assertk.assertThat +import assertk.assertions.cause +import assertk.assertions.containsExactly +import assertk.assertions.hasMessage +import assertk.assertions.isFailure +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isSameAs +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse +import com.fsck.k9.mail.store.imap.ImapStoreSettings.createExtra +import java.io.IOException +import java.util.ArrayDeque +import java.util.Deque +import org.junit.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.stub +import org.mockito.kotlin.verify + +class RealImapStoreTest { + private val imapStore = createTestImapStore() + + @Test + fun `checkSettings() should create ImapConnection and call open()`() { + val imapConnection = createMockConnection() + imapStore.enqueueImapConnection(imapConnection) + + imapStore.checkSettings() + + verify(imapConnection).open() + } + + @Test + fun `checkSettings() with open throwing should throw MessagingException`() { + val imapConnection = createMockConnection().stub { + on { open() } doThrow IOException::class + } + imapStore.enqueueImapConnection(imapConnection) + + assertThat { imapStore.checkSettings() }.isFailure() + .isInstanceOf(MessagingException::class).all { + hasMessage("Unable to connect") + cause().isNotNull().isInstanceOf(IOException::class) + } + } + + @Test + fun `getFolders() with SPECIAL-USE capability should return special FolderInfo`() { + val imapConnection = createMockConnection().stub { + on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true + on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true + on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf( + createImapResponse("""* LIST (\HasNoChildren) "/" "INBOX""""), + createImapResponse("""* LIST (\Noselect \HasChildren) "/" "[Gmail]""""), + createImapResponse("""* LIST (\HasNoChildren \All) "/" "[Gmail]/All Mail""""), + createImapResponse("""* LIST (\HasNoChildren \Drafts) "/" "[Gmail]/Drafts""""), + createImapResponse("""* LIST (\HasNoChildren \Important) "/" "[Gmail]/Important""""), + createImapResponse("""* LIST (\HasNoChildren \Sent) "/" "[Gmail]/Sent Mail""""), + createImapResponse("""* LIST (\HasNoChildren \Junk) "/" "[Gmail]/Spam""""), + createImapResponse("""* LIST (\HasNoChildren \Flagged) "/" "[Gmail]/Starred""""), + createImapResponse("""* LIST (\HasNoChildren \Trash) "/" "[Gmail]/Trash""""), + createImapResponse("5 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + + val folders = imapStore.getFolders() + + val foldersMap = folders.map { it.serverId to it.type } + assertThat(foldersMap).containsExactly( + "INBOX" to FolderType.INBOX, + "[Gmail]/All Mail" to FolderType.ARCHIVE, + "[Gmail]/Drafts" to FolderType.DRAFTS, + "[Gmail]/Important" to FolderType.REGULAR, + "[Gmail]/Sent Mail" to FolderType.SENT, + "[Gmail]/Spam" to FolderType.SPAM, + "[Gmail]/Starred" to FolderType.REGULAR, + "[Gmail]/Trash" to FolderType.TRASH + ) + } + + @Test + fun `getFolders() without SPECIAL-USE capability should use simple LIST command`() { + val imapConnection = createMockConnection().stub { + on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true + on { hasCapability(Capabilities.SPECIAL_USE) } doReturn false + } + imapStore.enqueueImapConnection(imapConnection) + + imapStore.getFolders() + + verify(imapConnection, never()).executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") + verify(imapConnection).executeSimpleCommand("""LIST "" "*"""") + } + + @Test + fun `getFolders() without LIST-EXTENDED capability should use simple LIST command`() { + val imapConnection = createMockConnection().stub { + on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn false + on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true + } + imapStore.enqueueImapConnection(imapConnection) + + imapStore.getFolders() + + verify(imapConnection, never()).executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") + verify(imapConnection).executeSimpleCommand("""LIST "" "*"""") + } + + @Test + fun `getFolders() with subscribedFoldersOnly = false`() { + val imapStore = createTestImapStore(isSubscribedFoldersOnly = false) + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf( + createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""), + createImapResponse("""* LIST (\Noselect \HasChildren) "." "Folder""""), + createImapResponse("""* LIST (\HasNoChildren) "." "Folder.SubFolder""""), + createImapResponse("6 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + + val folders = imapStore.getFolders() + + assertThat(folders).isNotNull() + assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder") + } + + @Test + fun `getFolders() with subscribedFoldersOnly = true should only return existing subscribed folders`() { + val imapStore = createTestImapStore(isSubscribedFoldersOnly = true) + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand("""LSUB "" "*"""") } doReturn listOf( + createImapResponse("""* LSUB (\HasNoChildren) "." "INBOX""""), + createImapResponse("""* LSUB (\Noselect \HasChildren) "." "Folder""""), + createImapResponse("""* LSUB (\HasNoChildren) "." "Folder.SubFolder""""), + createImapResponse("""* LSUB (\HasNoChildren) "." "SubscribedFolderThatHasBeenDeleted""""), + createImapResponse("5 OK Success") + ) + on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf( + createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""), + createImapResponse("""* LIST (\Noselect \HasChildren) "." "Folder""""), + createImapResponse("""* LIST (\HasNoChildren) "." "Folder.SubFolder""""), + createImapResponse("6 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + + val folders = imapStore.getFolders() + + assertThat(folders).isNotNull() + assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder") + } + + @Test + fun `getFolders() with namespace prefix`() { + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand("""LIST "" "INBOX.*"""") } doReturn listOf( + createImapResponse("""* LIST () "." "INBOX""""), + createImapResponse("""* LIST () "." "INBOX.FolderOne""""), + createImapResponse("""* LIST () "." "INBOX.FolderTwo""""), + createImapResponse("5 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + imapStore.setTestCombinedPrefix("INBOX.") + + val folders = imapStore.getFolders() + + assertThat(folders).isNotNull() + assertThat(folders.map { it.serverId }).containsExactly("INBOX", "INBOX.FolderOne", "INBOX.FolderTwo") + assertThat(folders.map { it.name }).containsExactly("INBOX", "FolderOne", "FolderTwo") + assertThat(folders.map { it.oldServerId }).containsExactly("INBOX", "FolderOne", "FolderTwo") + } + + @Test + fun `getFolders() with folder not matching namespace prefix`() { + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand("""LIST "" "INBOX.*"""") } doReturn listOf( + createImapResponse("""* LIST () "." "INBOX""""), + createImapResponse("""* LIST () "." "INBOX.FolderOne""""), + createImapResponse("""* LIST () "." "FolderTwo""""), + createImapResponse("5 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + imapStore.setTestCombinedPrefix("INBOX.") + + val folders = imapStore.getFolders() + + assertThat(folders).isNotNull() + assertThat(folders.map { it.serverId }).containsExactly("INBOX", "INBOX.FolderOne", "FolderTwo") + assertThat(folders.map { it.name }).containsExactly("INBOX", "FolderOne", "FolderTwo") + assertThat(folders.mapNotNull { it.oldServerId }).containsExactly("INBOX", "FolderOne") + } + + @Test + fun `getFolders() with duplicate folder names should remove duplicates and keep FolderType`() { + val imapConnection = createMockConnection().stub { + on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true + on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true + on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf( + createImapResponse("""* LIST () "." "INBOX""""), + createImapResponse("""* LIST (\HasNoChildren) "." "Junk""""), + createImapResponse("""* LIST (\Junk) "." "Junk""""), + createImapResponse("""* LIST (\HasNoChildren) "." "Junk""""), + createImapResponse("5 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + + val folders = imapStore.getFolders() + + assertThat(folders.map { it.serverId to it.type }).containsExactly( + "INBOX" to FolderType.INBOX, + "Junk" to FolderType.SPAM + ) + } + + @Test + fun `getFolders() without exception should leave ImapConnection open`() { + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand(anyString()) } doReturn listOf(createImapResponse("5 OK Success")) + } + imapStore.enqueueImapConnection(imapConnection) + + imapStore.getFolders() + + verify(imapConnection, never()).close() + } + + @Test + fun `getFolders() with IOException should close ImapConnection`() { + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand("""LIST "" "*"""") } doThrow IOException::class + } + imapStore.enqueueImapConnection(imapConnection) + + assertThat { imapStore.getFolders() }.isFailure() + .isInstanceOf(MessagingException::class) + + verify(imapConnection).close() + } + + @Test + fun `getConnection() should create ImapConnection`() { + val imapConnection = createMockConnection() + imapStore.enqueueImapConnection(imapConnection) + + val result = imapStore.getConnection() + + assertThat(result).isSameAs(imapConnection) + } + + @Test + fun `getConnection() called twice without release should create two ImapConnection instances`() { + val imapConnectionOne = createMockConnection() + val imapConnectionTwo = createMockConnection() + imapStore.enqueueImapConnection(imapConnectionOne) + imapStore.enqueueImapConnection(imapConnectionTwo) + + val resultOne = imapStore.getConnection() + val resultTwo = imapStore.getConnection() + + assertThat(resultOne).isSameAs(imapConnectionOne) + assertThat(resultTwo).isSameAs(imapConnectionTwo) + } + + @Test + fun `getConnection() called after release should return cached ImapConnection`() { + val imapConnection = createMockConnection().stub { + on { isConnected } doReturn true + } + imapStore.enqueueImapConnection(imapConnection) + + val connection = imapStore.getConnection() + imapStore.releaseConnection(connection) + + val result = imapStore.getConnection() + + assertThat(result).isSameAs(imapConnection) + } + + @Test + fun `getConnection() called after release with closed connection should return new ImapConnection instance`() { + val imapConnectionOne = createMockConnection() + val imapConnectionTwo = createMockConnection() + imapStore.enqueueImapConnection(imapConnectionOne) + imapStore.enqueueImapConnection(imapConnectionTwo) + + imapStore.getConnection() + imapConnectionOne.stub { + on { isConnected } doReturn false + } + imapStore.releaseConnection(imapConnectionOne) + + val result = imapStore.getConnection() + + assertThat(result).isSameAs(imapConnectionTwo) + } + + @Test + fun `getConnection() with dead connection in pool should return new ImapConnection instance`() { + val imapConnectionOne = createMockConnection() + val imapConnectionTwo = createMockConnection() + imapStore.enqueueImapConnection(imapConnectionOne) + imapStore.enqueueImapConnection(imapConnectionTwo) + + imapStore.getConnection() + imapConnectionOne.stub { + on { isConnected } doReturn true + on { executeSimpleCommand(Commands.NOOP) } doThrow IOException::class + } + imapStore.releaseConnection(imapConnectionOne) + + val result = imapStore.getConnection() + + assertThat(result).isSameAs(imapConnectionTwo) + } + + @Test + fun `getConnection() with connection in pool and closeAllConnections() should return new ImapConnection instance`() { + val imapConnectionOne = createMockConnection(1) + val imapConnectionTwo = createMockConnection(2) + imapStore.enqueueImapConnection(imapConnectionOne) + imapStore.enqueueImapConnection(imapConnectionTwo) + + imapStore.getConnection() + imapConnectionOne.stub { + on { isConnected } doReturn true + } + imapStore.releaseConnection(imapConnectionOne) + imapStore.closeAllConnections() + + val result = imapStore.getConnection() + + assertThat(result).isSameAs(imapConnectionTwo) + } + + @Test + fun `getConnection() with connection outside of pool and closeAllConnections() should return new ImapConnection instance`() { + val imapConnectionOne = createMockConnection(1) + val imapConnectionTwo = createMockConnection(2) + imapStore.enqueueImapConnection(imapConnectionOne) + imapStore.enqueueImapConnection(imapConnectionTwo) + + imapStore.getConnection() + imapConnectionOne.stub { + on { isConnected } doReturn true + } + imapStore.closeAllConnections() + imapStore.releaseConnection(imapConnectionOne) + + val result = imapStore.getConnection() + + assertThat(result).isSameAs(imapConnectionTwo) + } + + private fun createMockConnection(connectionGeneration: Int = 1): ImapConnection { + return mock { + on { this.connectionGeneration } doReturn connectionGeneration + } + } + + private fun createServerSettings(): ServerSettings { + return ServerSettings( + type = "imap", + host = "imap.example.org", + port = 143, + connectionSecurity = ConnectionSecurity.NONE, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + extra = createExtra(autoDetectNamespace = true, pathPrefix = null) + ) + } + + private fun createTestImapStore( + isSubscribedFoldersOnly: Boolean = false, + useCompression: Boolean = false + ): TestImapStore { + return TestImapStore( + serverSettings = createServerSettings(), + config = createImapStoreConfig(isSubscribedFoldersOnly, useCompression), + trustedSocketFactory = mock(), + oauth2TokenProvider = null + ) + } + + private fun createImapStoreConfig(isSubscribedFoldersOnly: Boolean, useCompression: Boolean): ImapStoreConfig { + return object : ImapStoreConfig { + override val logLabel: String = "irrelevant" + override fun isSubscribedFoldersOnly(): Boolean = isSubscribedFoldersOnly + override fun useCompression(): Boolean = useCompression + } + } + + private class TestImapStore( + serverSettings: ServerSettings, + config: ImapStoreConfig, + trustedSocketFactory: TrustedSocketFactory, + oauth2TokenProvider: OAuth2TokenProvider? + ) : RealImapStore( + serverSettings, + config, + trustedSocketFactory, + oauth2TokenProvider + ) { + private val imapConnections: Deque<ImapConnection> = ArrayDeque() + private var testCombinedPrefix: String? = null + + override fun createImapConnection(): ImapConnection { + if (imapConnections.isEmpty()) { + throw AssertionError("Unexpectedly tried to create an ImapConnection instance") + } + + return imapConnections.pop() + } + + fun enqueueImapConnection(imapConnection: ImapConnection) { + imapConnections.add(imapConnection) + } + + override fun getCombinedPrefix(): String { + return testCombinedPrefix ?: super.getCombinedPrefix() + } + + fun setTestCombinedPrefix(prefix: String?) { + testCombinedPrefix = prefix + } + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractorTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractorTest.java new file mode 100644 index 0000000..df5b92d --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractorTest.java @@ -0,0 +1,38 @@ +package com.fsck.k9.mail.store.imap; + + +import org.junit.Test; + +import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + + +public class ResponseCodeExtractorTest { + @Test + public void getResponseCode_withResponseCode() throws Exception { + ImapResponse imapResponse = createImapResponse("x NO [AUTHENTICATIONFAILED] No sir"); + + String result = ResponseCodeExtractor.getResponseCode(imapResponse); + + assertEquals("AUTHENTICATIONFAILED", result); + } + + @Test + public void getResponseCode_withoutResponseCode() throws Exception { + ImapResponse imapResponse = createImapResponse("x NO Authentication failed"); + + String result = ResponseCodeExtractor.getResponseCode(imapResponse); + + assertNull(result); + } + + @Test + public void getResponseCode_withoutSingleItemResponse() throws Exception { + ImapResponse imapResponse = createImapResponse("x NO"); + + String result = ResponseCodeExtractor.getResponseCode(imapResponse); + + assertNull(result); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseTextExtractorTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseTextExtractorTest.kt new file mode 100644 index 0000000..f40e77f --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseTextExtractorTest.kt @@ -0,0 +1,45 @@ +package com.fsck.k9.mail.store.imap + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse +import org.junit.Test + +class ResponseTextExtractorTest { + @Test + fun `response with response code and response text`() { + val imapResponse: ImapResponse = createImapResponse("x NO [AUTHENTICATIONFAILED] Authentication error #23") + + val result = ResponseTextExtractor.getResponseText(imapResponse) + + assertThat(result).isEqualTo("Authentication error #23") + } + + @Test + fun `response with only response text`() { + val imapResponse: ImapResponse = createImapResponse("x NO AUTHENTICATE failed") + + val result = ResponseTextExtractor.getResponseText(imapResponse) + + assertThat(result).isEqualTo("AUTHENTICATE failed") + } + + @Test + fun `response without response code or text`() { + val imapResponse: ImapResponse = createImapResponse("x NO") + + val result = ResponseTextExtractor.getResponseText(imapResponse) + + assertThat(result).isNull() + } + + @Test + fun `response with only a response code`() { + val imapResponse: ImapResponse = createImapResponse("x NO [AUTHENTICATIONFAILED]") + + val result = ResponseTextExtractor.getResponseText(imapResponse) + + assertThat(result).isNull() + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SearchResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SearchResponseTest.java new file mode 100644 index 0000000..ec7bc2c --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SearchResponseTest.java @@ -0,0 +1,89 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.Collections; +import java.util.List; + +import org.junit.Test; + +import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponseList; +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + + +public class SearchResponseTest { + @Test + public void parse_withSingleSearchResponse_shouldExtractNumbers() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList( + "* SEARCH 1 2 3", + "* 23 EXISTS", + "* SEARCH 4", + "1 OK SEARCH completed"); + + SearchResponse result = SearchResponse.parse(imapResponses); + + assertNotNull(result); + assertEquals(asList(1L, 2L, 3L, 4L), result.getNumbers()); + } + + @Test + public void parse_withMultipleSearchResponses_shouldExtractNumbers() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList( + "* SEARCH 1 2 3", + "* 23 EXISTS", + "* SEARCH 4", + "1 OK SEARCH completed", + "* SEARCH 5 6", + "* 19 EXPUNGED", + "* SEARCH 7", + "2 OK SEARCH completed", + "* SEARCH 8", + "3 OK SEARCH completed"); + + SearchResponse result = SearchResponse.parse(imapResponses); + + assertNotNull(result); + assertEquals(asList(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L), result.getNumbers()); + } + + @Test + public void parse_withSingleTaggedSearchResponse_shouldReturnEmptyList() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x SEARCH 7 8 9"); + + SearchResponse result = SearchResponse.parse(imapResponses); + + assertNotNull(result); + assertEquals(Collections.emptyList(), result.getNumbers()); + } + + @Test + public void parse_withSingleTooShortResponse_shouldReturnEmptyList() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("* SEARCH"); + + SearchResponse result = SearchResponse.parse(imapResponses); + + assertNotNull(result); + assertEquals(Collections.emptyList(), result.getNumbers()); + } + + @Test + public void parse_withSingleNoSearchResponse_shouldReturnEmptyList() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("* 23 EXPUNGE"); + + SearchResponse result = SearchResponse.parse(imapResponses); + + assertNotNull(result); + assertEquals(Collections.emptyList(), result.getNumbers()); + } + + @Test + public void parse_withSingleSearchResponseContainingInvalidNumber_shouldReturnEmptyList() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("* SEARCH A"); + + SearchResponse result = SearchResponse.parse(imapResponses); + + assertNotNull(result); + assertEquals(Collections.emptyList(), result.getNumbers()); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponseTest.java new file mode 100644 index 0000000..568f35c --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponseTest.java @@ -0,0 +1,96 @@ +package com.fsck.k9.mail.store.imap; + + +import org.junit.Test; + +import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; + + +public class SelectOrExamineResponseTest { + @Test + public void parse_withSelectResponse_shouldReturnOpenModeReadWrite() throws Exception { + ImapResponse imapResponse = createImapResponse("x OK [READ-WRITE] Select completed."); + + SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse); + + assertNotNull(result); + assertEquals(true, result.hasOpenMode()); + assertEquals(OpenMode.READ_WRITE, result.getOpenMode()); + } + + @Test + public void parse_withExamineResponse_shouldReturnOpenModeReadOnly() throws Exception { + ImapResponse imapResponse = createImapResponse("x OK [READ-ONLY] Examine completed."); + + SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse); + + assertNotNull(result); + assertEquals(true, result.hasOpenMode()); + assertEquals(OpenMode.READ_ONLY, result.getOpenMode()); + } + + @Test + public void parse_withoutResponseCode_shouldReturnHasOpenModeFalse() throws Exception { + ImapResponse imapResponse = createImapResponse("x OK Select completed."); + + SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse); + + assertNotNull(result); + assertEquals(false, result.hasOpenMode()); + } + + @Test + public void getOpenMode_withoutResponseCode_shouldThrow() throws Exception { + ImapResponse imapResponse = createImapResponse("x OK Select completed."); + SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse); + assertNotNull(result); + + try { + result.getOpenMode(); + fail("Expected exception"); + } catch (IllegalStateException ignored) { + } + } + + @Test + public void parse_withInvalidResponseText_shouldReturnHasOpenModeFalse() throws Exception { + ImapResponse imapResponse = createImapResponse("x OK [()] Examine completed."); + + SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse); + + assertNotNull(result); + assertEquals(false, result.hasOpenMode()); + } + + @Test + public void parse_withUnknownResponseText_shouldReturnHasOpenModeFalse() throws Exception { + ImapResponse imapResponse = createImapResponse("x OK [FUNKY] Examine completed."); + + SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse); + + assertNotNull(result); + assertEquals(false, result.hasOpenMode()); + } + + @Test + public void parse_withUntaggedResponse_shouldReturnNull() throws Exception { + ImapResponse imapResponse = createImapResponse("* OK [READ-WRITE] Select completed."); + + SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse); + + assertNull(result); + } + + @Test + public void parse_withoutOkResponse_shouldReturnNull() throws Exception { + ImapResponse imapResponse = createImapResponse("x BYE"); + + SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse); + + assertNull(result); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt new file mode 100644 index 0000000..5e75c23 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt @@ -0,0 +1,21 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity + +internal class SimpleImapSettings( + override val host: String, + override val port: Int = 0, + override val connectionSecurity: ConnectionSecurity = ConnectionSecurity.NONE, + override val authType: AuthType, + override val username: String, + override val password: String? = null, + override val useCompression: Boolean = false +) : ImapSettings { + override val clientCertificateAlias: String? = null + + override var pathPrefix: String? = null + override var pathDelimiter: String? = null + + override fun setCombinedPrefix(prefix: String?) = Unit +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestIdleRefreshManager.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestIdleRefreshManager.kt new file mode 100644 index 0000000..ce764be --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestIdleRefreshManager.kt @@ -0,0 +1,35 @@ +package com.fsck.k9.mail.store.imap + +class TestIdleRefreshManager : IdleRefreshManager { + private val timers = mutableListOf<TestIdleRefreshTimer>() + + @Synchronized + override fun startTimer(timeout: Long, callback: () -> Unit): TestIdleRefreshTimer { + return TestIdleRefreshTimer(timeout, callback).also { timer -> timers.add(timer) } + } + + @Synchronized + override fun resetTimers() { + for (timer in timers) { + timer.trigger() + } + timers.clear() + } + + fun getTimeoutValue(): Long? = timers.map { it.timeout }.minOrNull() +} + +class TestIdleRefreshTimer(val timeout: Long, private val callback: () -> Unit) : IdleRefreshTimer { + override var isWaiting: Boolean = true + private set + + @Synchronized + override fun cancel() { + isWaiting = false + } + + fun trigger() { + isWaiting = false + callback() + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapConnection.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapConnection.kt new file mode 100644 index 0000000..5bfd672 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapConnection.kt @@ -0,0 +1,135 @@ +package com.fsck.k9.mail.store.imap + +import java.io.OutputStream +import java.util.concurrent.LinkedBlockingDeque +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +internal open class TestImapConnection(val timeout: Long, override val connectionGeneration: Int = 1) : ImapConnection { + override val logId: String = "testConnection" + override var isConnected: Boolean = false + protected set + override val outputStream: OutputStream + get() = TODO("Not yet implemented") + override val isUidPlusCapable: Boolean = true + override var isIdleCapable: Boolean = true + protected set + + val defaultSocketReadTimeout = 30 * 1000 + var currentSocketReadTimeout = defaultSocketReadTimeout + protected set + + @Volatile + private var tag: Int = 0 + + private val receivedCommands = LinkedBlockingDeque<String>() + private val responses = LinkedBlockingDeque<Response>() + + private val readResponseLock = ReentrantLock() + private val readResponseLockCondition = readResponseLock.newCondition() + + override fun open() { + isConnected = true + } + + override fun close() { + isConnected = false + } + + override fun hasCapability(capability: String): Boolean { + throw UnsupportedOperationException("not implemented") + } + + override fun executeSimpleCommand(command: String): List<ImapResponse> { + throw UnsupportedOperationException("not implemented") + } + + override fun executeCommandWithIdSet( + commandPrefix: String, + commandSuffix: String, + ids: Set<Long> + ): List<ImapResponse> { + throw UnsupportedOperationException("not implemented") + } + + override fun sendCommand(command: String, sensitive: Boolean): String { + val tag = ++tag + println(">>> $tag $command") + + receivedCommands.add(command) + return tag.toString() + } + + override fun sendContinuation(continuation: String) { + println(">>> $continuation") + receivedCommands.add(continuation) + } + + override fun readResponse(): ImapResponse { + readResponseLock.withLock { + readResponseLockCondition.signal() + } + val imapResponse = when (val response = responses.take()) { + is Response.Continuation -> ImapResponseHelper.createImapResponse("+ ${response.text}") + is Response.Tagged -> ImapResponseHelper.createImapResponse("$tag ${response.response}") + is Response.Untagged -> ImapResponseHelper.createImapResponse("* ${response.response}") + is Response.Action -> response.action() + } + + println("<<< $imapResponse") + + return imapResponse + } + + override fun readResponse(callback: ImapResponseCallback?): ImapResponse { + throw UnsupportedOperationException("not implemented") + } + + override fun setSocketDefaultReadTimeout() { + currentSocketReadTimeout = defaultSocketReadTimeout + } + + override fun setSocketReadTimeout(timeout: Int) { + currentSocketReadTimeout = timeout + } + + fun waitForCommand(command: String) { + do { + val receivedCommand = receivedCommands.poll(timeout, TimeUnit.SECONDS) ?: throw AssertionError("Timeout") + } while (receivedCommand != command) + } + + fun waitForBlockingRead() { + readResponseLock.withLock { + readResponseLockCondition.await(timeout, TimeUnit.SECONDS) + } + } + + fun throwOnRead(block: () -> Nothing) { + responses.add(Response.Action(block)) + } + + fun enqueueTaggedServerResponse(response: String) { + responses.add(Response.Tagged(response)) + } + + fun enqueueUntaggedServerResponse(response: String) { + responses.add(Response.Untagged(response)) + } + + fun enqueueContinuationServerResponse(text: String = "") { + responses.add(Response.Continuation(text)) + } + + fun setIdleNotSupported() { + isIdleCapable = false + } +} + +private sealed class Response { + class Tagged(val response: String) : Response() + class Untagged(val response: String) : Response() + class Continuation(val text: String) : Response() + class Action(val action: () -> ImapResponse) : Response() +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapFolder.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapFolder.kt new file mode 100644 index 0000000..495280a --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapFolder.kt @@ -0,0 +1,126 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.BodyFactory +import com.fsck.k9.mail.FetchProfile +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.MessageRetrievalListener +import com.fsck.k9.mail.Part +import java.util.Date + +internal open class TestImapFolder( + override val serverId: String, + val connection: TestImapConnection +) : ImapFolder { + override var mode: OpenMode? = null + protected set + + override var messageCount: Int = 0 + protected set + + var isOpen: Boolean = false + protected set + + private var openAction: () -> Unit = {} + + override fun exists(): Boolean { + throw UnsupportedOperationException("not implemented") + } + + override fun open(mode: OpenMode) { + openAction() + this.mode = mode + connection.open() + isOpen = true + } + + override fun close() { + connection.close() + isOpen = false + mode = null + } + + override fun getUidValidity(): Long? { + throw UnsupportedOperationException("not implemented") + } + + override fun getMessage(uid: String): ImapMessage { + throw UnsupportedOperationException("not implemented") + } + + override fun getUidFromMessageId(messageId: String): String? { + throw UnsupportedOperationException("not implemented") + } + + override fun getMessages( + start: Int, + end: Int, + earliestDate: Date?, + listener: MessageRetrievalListener<ImapMessage>? + ): List<ImapMessage> { + throw UnsupportedOperationException("not implemented") + } + + override fun areMoreMessagesAvailable(indexOfOldestMessage: Int, earliestDate: Date?): Boolean { + throw UnsupportedOperationException("not implemented") + } + + override fun fetch( + messages: List<ImapMessage>, + fetchProfile: FetchProfile, + listener: FetchListener?, + maxDownloadSize: Int + ) { + throw UnsupportedOperationException("not implemented") + } + + override fun fetchPart( + message: ImapMessage, + part: Part, + bodyFactory: BodyFactory, + maxDownloadSize: Int + ) { + throw UnsupportedOperationException("not implemented") + } + + override fun search( + queryString: String?, + requiredFlags: Set<Flag>?, + forbiddenFlags: Set<Flag>?, + performFullTextSearch: Boolean + ): List<ImapMessage> { + throw UnsupportedOperationException("not implemented") + } + + override fun appendMessages(messages: List<Message>): Map<String, String>? { + throw UnsupportedOperationException("not implemented") + } + + override fun setFlags(flags: Set<Flag>, value: Boolean) { + throw UnsupportedOperationException("not implemented") + } + + override fun setFlags(messages: List<ImapMessage>, flags: Set<Flag>, value: Boolean) { + throw UnsupportedOperationException("not implemented") + } + + override fun copyMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? { + throw UnsupportedOperationException("not implemented") + } + + override fun moveMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? { + throw UnsupportedOperationException("not implemented") + } + + override fun expunge() { + throw UnsupportedOperationException("not implemented") + } + + override fun expungeUids(uids: List<String>) { + throw UnsupportedOperationException("not implemented") + } + + fun throwOnOpen(block: () -> Nothing) { + openAction = block + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapStore.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapStore.kt new file mode 100644 index 0000000..a347ead --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapStore.kt @@ -0,0 +1,24 @@ +package com.fsck.k9.mail.store.imap + +internal class TestImapStore(private val folder: ImapFolder) : ImapStore, ImapConnectionProvider { + override fun checkSettings() { + throw UnsupportedOperationException("not implemented") + } + + override fun getFolder(name: String): ImapFolder { + return folder + } + + override fun getFolders(): List<FolderListItem> { + throw UnsupportedOperationException("not implemented") + } + + override fun getConnection(folder: ImapFolder): ImapConnection { + if (folder !is TestImapFolder) throw AssertionError("getConnection() called with unknown ImapFolder instance") + return folder.connection + } + + override fun closeAllConnections() { + throw UnsupportedOperationException("not implemented") + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestWakeLock.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestWakeLock.kt new file mode 100644 index 0000000..4beb1e5 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestWakeLock.kt @@ -0,0 +1,47 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.power.WakeLock +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +class TestWakeLock(private val timeoutSeconds: Long, isHeld: Boolean = false) : WakeLock { + var isHeld = isHeld + private set + + private val lock = ReentrantLock() + private val lockCondition = lock.newCondition() + + override fun acquire() { + lock.withLock { + if (isHeld) throw AssertionError("Tried to acquire wakelock we're already holding") + + isHeld = true + } + } + + override fun release() { + lock.withLock { + if (!isHeld) throw AssertionError("Tried to release a wakelock we're not holding") + + isHeld = false + lockCondition.signal() + } + } + + override fun acquire(timeout: Long) { + throw UnsupportedOperationException("not implemented") + } + + override fun setReferenceCounted(counted: Boolean) { + throw UnsupportedOperationException("not implemented") + } + + fun waitForRelease() { + lock.withLock { + if (isHeld) { + lockCondition.await(timeoutSeconds, TimeUnit.SECONDS) + } + } + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.java new file mode 100644 index 0000000..cb929b8 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.java @@ -0,0 +1,139 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponseList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + + +public class UidCopyResponseTest { + @Test + public void parse_withCopyUidResponse_shouldCreateUidMapping() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID 1 1,3:5 7:10] Success"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNotNull(result); + assertEquals(createUidMapping("1=7", "3=8", "4=9", "5=10"), result.getUidMapping()); + } + + @Test + public void parse_withUntaggedResponse_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponse = createImapResponseList("* OK [COPYUID 1 1,3:5 7:10] Success"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponse); + + assertNull(result); + } + + @Test + public void parse_withTooShortResponse_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x OK"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNull(result); + } + + @Test + public void parse_withoutOkResponse_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x BYE Logout"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNull(result); + } + + @Test + public void parse_withoutResponseTextList_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x OK Success"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNull(result); + } + + @Test + public void parse_withResponseTextListTooShort_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x OK [A B C] Success"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNull(result); + } + + @Test + public void parse_withoutCopyUidResponse_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x OK [A B C D] Success"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNull(result); + } + + @Test + public void parse_withNonStringCopyUidArgumentOne_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID () C D] Success"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNull(result); + } + + @Test + public void parse_withNonStringCopyUidArgumentTwo_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID B () D] Success"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNull(result); + } + + @Test + public void parse_withNonStringCopyUidArgumentThree_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID B C ()] Success"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNull(result); + } + + @Test + public void parse_withNonNumberCopyUidArguments_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID B C D] Success"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNull(result); + } + + @Test + public void parse_withUnbalancedCopyUidArguments_shouldReturnNull() throws Exception { + List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID B 1 1,2] Success"); + + UidCopyResponse result = UidCopyResponse.parse(imapResponses); + + assertNull(result); + } + + + private Map<String, String> createUidMapping(String... values) { + Map<String, String> mapping = new HashMap<>(values.length); + + for (String value : values) { + String[] parts = value.split("="); + String oldUid = parts[0]; + String newUid = parts[1]; + mapping.put(oldUid, newUid); + } + + return mapping; + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java new file mode 100644 index 0000000..1d78cea --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java @@ -0,0 +1,38 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.Collections; + +import com.fsck.k9.mail.Flag; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class UidSearchCommandBuilderTest { + + @Test + public void build_withFullTextSearch() { + String command = new UidSearchCommandBuilder() + .performFullTextSearch(true) + .requiredFlags(Collections.singleton(Flag.FLAGGED)) + .forbiddenFlags(Collections.singleton(Flag.DELETED)) + .queryString("query") + .build(); + + assertEquals("UID SEARCH TEXT \"query\" FLAGGED NOT DELETED", command); + } + + @Test + public void build_withoutFullTextSearch() { + String command = new UidSearchCommandBuilder() + .performFullTextSearch(false) + .requiredFlags(null) + .forbiddenFlags(Collections.singleton(Flag.DELETED)) + .queryString("query") + .build(); + + assertEquals("UID SEARCH OR OR OR OR SUBJECT \"query\" FROM \"query\" TO \"query\" CC \"query\"" + + " BCC \"query\" NOT DELETED", command); + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidValidityResponseTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidValidityResponseTest.kt new file mode 100644 index 0000000..9d6f842 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidValidityResponseTest.kt @@ -0,0 +1,63 @@ +package com.fsck.k9.mail.store.imap + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class UidValidityResponseTest { + @Test + fun validResponseWithText() { + val response = ImapResponseHelper.createImapResponse("* OK [UIDVALIDITY 23] UIDs valid") + + val result = UidValidityResponse.parse(response) + + assertNotNull(result) + assertEquals(23, result!!.uidValidity) + } + + @Test + fun validResponseWithoutText() { + val response = ImapResponseHelper.createImapResponse("* OK [UIDVALIDITY 42]") + + val result = UidValidityResponse.parse(response) + + assertNotNull(result) + assertEquals(42, result!!.uidValidity) + } + + @Test + fun taggedResponse_shouldReturnNull() { + assertNotValid("99 OK [UIDVALIDITY 42]") + } + + @Test + fun noResponse_shouldReturnNull() { + assertNotValid("* NO [UIDVALIDITY 42]") + } + + @Test + fun responseTextWithOnlyOneItem_shouldReturnNull() { + assertNotValid("* OK [UIDVALIDITY]") + } + + @Test + fun uidValidityIsNotANumber_shouldReturnNull() { + assertNotValid("* OK [UIDVALIDITY fourtytwo]") + } + + @Test + fun negativeUidValidity_shouldReturnNull() { + assertNotValid("* OK [UIDVALIDITY -1]") + } + + @Test + fun uidValidityOutsideRange_shouldReturnNull() { + assertNotValid("* OK [UIDVALIDITY 4294967296]") + } + + private fun assertNotValid(response: String) { + val result = UidValidityResponse.parse(ImapResponseHelper.createImapResponse(response)) + assertNull(result) + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/mockserver/MockImapServer.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/mockserver/MockImapServer.java new file mode 100644 index 0000000..140fa61 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/mockserver/MockImapServer.java @@ -0,0 +1,420 @@ +package com.fsck.k9.mail.store.imap.mockserver; + + +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 MockImapServer { + 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 MockImapServer() { + this(KeyStoreProvider.getInstance(), new DefaultLogger()); + } + + public MockImapServer(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("MockImapServer: " + message); + } + + @Override + public void log(String format, Object... args) { + log(String.format(format, args)); + } + } +} |
