blob: df7613f3e19a4f395e4b0676dc39f09a1f6159b6 [file] [log] [blame]
/****************************************************************
* Licensed to the Apache Software Foundation (ASF) under one *
* or more contributor license agreements. See the NOTICE file *
* distributed with this work for additional information *
* regarding copyright ownership. The ASF licenses this file *
* to you 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 org.apache.james.webadmin.integration.vault;
import static io.restassured.RestAssured.given;
import static io.restassured.RestAssured.with;
import static io.restassured.config.ParamConfig.UpdateStrategy.REPLACE;
import static io.restassured.http.ContentType.JSON;
import static org.apache.james.jmap.JMAPTestingConstants.DOMAIN;
import static org.apache.james.jmap.JMAPTestingConstants.LOCALHOST_IP;
import static org.apache.james.jmap.JMAPTestingConstants.calmlyAwait;
import static org.apache.james.jmap.JMAPTestingConstants.jmapRequestSpecBuilder;
import static org.apache.james.jmap.JmapRFCCommonRequests.ACCEPT_JMAP_RFC_HEADER;
import static org.apache.james.jmap.JmapRFCCommonRequests.UserCredential;
import static org.apache.james.jmap.JmapRFCCommonRequests.deleteMessages;
import static org.apache.james.jmap.JmapRFCCommonRequests.getAllMailboxesIds;
import static org.apache.james.jmap.JmapRFCCommonRequests.getLatestMessageId;
import static org.apache.james.jmap.JmapRFCCommonRequests.getMessageContent;
import static org.apache.james.jmap.JmapRFCCommonRequests.getOutboxId;
import static org.apache.james.jmap.JmapRFCCommonRequests.getUserCredential;
import static org.apache.james.jmap.JmapRFCCommonRequests.listMessageIdsForAccount;
import static org.apache.james.jmap.JmapRFCCommonRequests.listMessageIdsInMailbox;
import static org.apache.james.mailbox.backup.ZipAssert.EntryChecks.hasName;
import static org.apache.james.mailbox.backup.ZipAssert.assertThatZip;
import static org.apache.james.webadmin.integration.vault.DeletedMessagesVaultRequests.deleteFromVault;
import static org.apache.james.webadmin.integration.vault.DeletedMessagesVaultRequests.exportVaultContent;
import static org.apache.james.webadmin.integration.vault.DeletedMessagesVaultRequests.purgeVault;
import static org.apache.james.webadmin.integration.vault.DeletedMessagesVaultRequests.restoreMessagesForUserWithQuery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Durations.FIVE_SECONDS;
import static org.awaitility.Durations.TWO_MINUTES;
import static org.hamcrest.Matchers.notNullValue;
import java.io.FileInputStream;
import java.io.IOException;
import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import org.apache.james.GuiceJamesServer;
import org.apache.james.GuiceModuleTestExtension;
import org.apache.james.jmap.JmapGuiceProbe;
import org.apache.james.junit.categories.BasicFeature;
import org.apache.james.mailbox.DefaultMailboxes;
import org.apache.james.mailbox.Role;
import org.apache.james.mailbox.backup.ZipAssert;
import org.apache.james.mailbox.model.MailboxId;
import org.apache.james.mailbox.probe.MailboxProbe;
import org.apache.james.modules.MailboxProbeImpl;
import org.apache.james.modules.protocols.ImapGuiceProbe;
import org.apache.james.probe.DataProbe;
import org.apache.james.util.Port;
import org.apache.james.utils.DataProbeImpl;
import org.apache.james.utils.TestIMAPClient;
import org.apache.james.utils.UpdatableTickingClock;
import org.apache.james.utils.WebAdminGuiceProbe;
import org.apache.james.webadmin.WebAdminUtils;
import org.awaitility.core.ConditionFactory;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import com.google.common.collect.ImmutableList;
import com.google.inject.Module;
import io.restassured.RestAssured;
import io.restassured.config.ParamConfig;
import io.restassured.parsing.Parser;
import io.restassured.specification.RequestSpecification;
public abstract class DeletedMessageVaultIntegrationTest {
public static class ClockExtension implements GuiceModuleTestExtension {
private UpdatableTickingClock clock;
@Override
public void beforeEach(ExtensionContext extensionContext) {
clock = new UpdatableTickingClock(NOW.toInstant());
}
@Override
public Module getModule() {
return binder -> binder.bind(Clock.class).toInstance(clock);
}
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
return parameterContext.getParameter().getType() == UpdatableTickingClock.class;
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
return clock;
}
}
private static final ZonedDateTime NOW = ZonedDateTime.now();
private static final ZonedDateTime TWO_MONTH_AFTER_ONE_YEAR_EXPIRATION = NOW.plusYears(1).plusMonths(2);
private static final String FIRST_SUBJECT = "first subject";
private static final String SECOND_SUBJECT = "second subject";
private static final String HOMER = "homer@" + DOMAIN;
private static final String BART = "bart@" + DOMAIN;
private static final String JACK = "jack@" + DOMAIN;
private static final String PASSWORD = "password";
private static final String BOB_PASSWORD = "bobPassword";
private static final ConditionFactory WAIT_TWO_MINUTES = calmlyAwait.atMost(TWO_MINUTES);
private static final String SUBJECT = "This mail will be restored from the vault!!";
private static final String MAILBOX_NAME = "toBeDeleted";
private static final String MATCH_ALL_QUERY = "{" +
"\"combinator\": \"and\"," +
"\"criteria\": []" +
"}";
private static final ExportRequest EXPORT_ALL_HOMER_MESSAGES_TO_BART = ExportRequest
.userExportFrom(HOMER)
.exportTo(BART)
.query(MATCH_ALL_QUERY);
private static final ExportRequest EXPORT_ALL_JACK_MESSAGES_TO_HOMER = ExportRequest
.userExportFrom(JACK)
.exportTo(HOMER)
.query(MATCH_ALL_QUERY);
private TestIMAPClient testIMAPClient;
private RequestSpecification webAdminApi;
private MailboxId otherMailboxId;
private UserCredential homerCredential;
private UserCredential bartCredential;
private UserCredential jackCredential;
@BeforeEach
void setup(GuiceJamesServer jmapServer) throws Throwable {
MailboxProbe mailboxProbe = jmapServer.getProbe(MailboxProbeImpl.class);
DataProbe dataProbe = jmapServer.getProbe(DataProbeImpl.class);
Port jmapPort = jmapServer.getProbe(JmapGuiceProbe.class).getJmapPort();
RestAssured.requestSpecification = jmapRequestSpecBuilder
.setPort(jmapPort.getValue())
.addHeader(ACCEPT_JMAP_RFC_HEADER.getName(), ACCEPT_JMAP_RFC_HEADER.getValue())
.build();
RestAssured.defaultParser = Parser.JSON;
dataProbe.addDomain(DOMAIN);
dataProbe.addUser(HOMER, PASSWORD);
dataProbe.addUser(BART, BOB_PASSWORD);
dataProbe.addUser(JACK, PASSWORD);
mailboxProbe.createMailbox("#private", HOMER, DefaultMailboxes.INBOX);
otherMailboxId = mailboxProbe.createMailbox("#private", HOMER, MAILBOX_NAME);
homerCredential = getUserCredential(HOMER, PASSWORD);
bartCredential = getUserCredential(BART, BOB_PASSWORD);
jackCredential = getUserCredential(JACK, PASSWORD);
testIMAPClient = new TestIMAPClient();
webAdminApi = WebAdminUtils.spec(jmapServer.getProbe(WebAdminGuiceProbe.class).getWebAdminPort())
.config(WebAdminUtils.defaultConfig()
.paramConfig(new ParamConfig(REPLACE, REPLACE, REPLACE)));
}
@AfterEach
void tearDown() throws IOException {
testIMAPClient.close();
}
protected abstract void awaitSearchUpToDate();
@Tag(BasicFeature.TAG)
@Test
void vaultEndpointShouldRestoreJmapDeletedEmail() {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
restoreAllMessagesOfHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageId = listMessageIdsForAccount(homerCredential).get(0);
assertThat(getMessageContent(homerCredential, messageId)
.getString("methodResponses[0][1].list[0].subject")).isEqualTo(SUBJECT);
}
@Tag(BasicFeature.TAG)
@Test
void vaultEndpointShouldRestoreImapDeletedEmail(GuiceJamesServer jmapServer) throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
testIMAPClient.connect(LOCALHOST_IP, jmapServer.getProbe(ImapGuiceProbe.class).getImapPort())
.login(HOMER, PASSWORD)
.select(TestIMAPClient.INBOX)
.setFlagsForAllMessagesInMailbox("\\Deleted");
testIMAPClient.expunge();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
restoreAllMessagesOfHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageId = listMessageIdsForAccount(homerCredential).get(0);
assertThat(getMessageContent(homerCredential, messageId)
.getString("methodResponses[0][1].list[0].subject")).isEqualTo(SUBJECT);
}
@Tag(BasicFeature.TAG)
@Test
void vaultEndpointShouldRestoreImapDeletedMailbox(GuiceJamesServer jmapServer) throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
testIMAPClient.connect(LOCALHOST_IP, jmapServer.getProbe(ImapGuiceProbe.class).getImapPort())
.login(HOMER, PASSWORD)
.select(TestIMAPClient.INBOX);
testIMAPClient.moveFirstMessage(MAILBOX_NAME);
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsInMailbox(homerCredential, otherMailboxId.serialize())).hasSize(1));
testIMAPClient.delete(MAILBOX_NAME);
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
Thread.sleep(1000); // Wait for messages to be moved to the vault
restoreAllMessagesOfHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageId = listMessageIdsForAccount(homerCredential).get(0);
assertThat(getMessageContent(homerCredential, messageId)
.getString("methodResponses[0][1].list[0].subject")).isEqualTo(SUBJECT);
}
@Test
void restoreShouldCreateRestoreMessagesMailbox() {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
restoreAllMessagesOfHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
assertThat(homerHasMailboxWithRole(Role.RESTORED_MESSAGES)).isTrue();
}
@Test
void postShouldRestoreMatchingMessages() {
bartSendMessageToHomerWithSubject("aaaaa");
bartSendMessageToHomerWithSubject("bbbbb");
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(2));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
String query = "{" +
" \"combinator\": \"and\"," +
" \"criteria\": [" +
" {" +
" \"fieldName\": \"subject\"," +
" \"operator\": \"equals\"," +
" \"value\": \"aaaaa\"" +
" }" +
" ]" +
"}";
restoreMessagesForUserWithQuery(webAdminApi, HOMER, query);
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageId = listMessageIdsForAccount(homerCredential).get(0);
assertThat(getMessageContent(homerCredential, messageId)
.getString("methodResponses[0][1].list[0].subject")).isEqualTo("aaaaa");
}
@Test
void postShouldNotRestoreWhenNoMatchingMessages() throws Exception {
bartSendMessageToHomerWithSubject("aaaaa");
bartSendMessageToHomerWithSubject("bbbbb");
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(2));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
String query = "{" +
" \"combinator\": \"and\"," +
" \"criteria\": [" +
" {" +
" \"fieldName\": \"subject\"," +
" \"operator\": \"equals\"," +
" \"value\": \"ccccc\"" +
" }" +
" ]" +
"}";
restoreMessagesForUserWithQuery(webAdminApi, HOMER, query);
Thread.sleep(FIVE_SECONDS.toMillis());
// No additional had been restored for Bart as the vault is empty
assertThat(listMessageIdsForAccount(homerCredential).size())
.isEqualTo(0);
}
@Test
void postShouldRestoreMatchingMessagesWhenQueryLimit() {
bartSendMessageToHomerWithSubject("aaaa");
bartSendMessageToHomerWithSubject("aaaa");
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(2));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
String query = "{" +
" \"combinator\": \"and\"," +
" \"limit\": 1," +
" \"criteria\": [" +
" {" +
" \"fieldName\": \"subject\"," +
" \"operator\": \"equals\"," +
" \"value\": \"aaaa\"" +
" }" +
" ]" +
"}";
restoreMessagesForUserWithQuery(webAdminApi, HOMER, query);
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
}
@Test
void imapMovedMessageShouldNotEndUpInTheVault(GuiceJamesServer jmapServer) throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
testIMAPClient.connect(LOCALHOST_IP, jmapServer.getProbe(ImapGuiceProbe.class).getImapPort())
.login(HOMER, PASSWORD)
.select(TestIMAPClient.INBOX);
testIMAPClient.moveFirstMessage(MAILBOX_NAME);
//Moved messages should not be moved to the vault
restoreAllMessagesOfHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
// No messages restored for bart
assertThat(listMessageIdsForAccount(homerCredential).size()).isEqualTo(1);
}
@Test
void jmapMovedMessageShouldNotEndUpInTheVault() {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageId = listMessageIdsForAccount(homerCredential).get(0);
homerMovesTheMailInAnotherMailbox(messageId);
//Moved messages should not be moved to the vault
restoreAllMessagesOfHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
// No messages restored for bart
assertThat(listMessageIdsForAccount(homerCredential).size()).isEqualTo(1);
}
@Test
void restoreShouldNotImpactOtherUsers() {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
bartDeletesMessages(listMessageIdsForAccount(bartCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(bartCredential)).hasSize(0));
restoreAllMessagesOfHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
// No messages restored for bart
assertThat(listMessageIdsForAccount(bartCredential).size()).isEqualTo(0);
}
@Test
void restoredMessagesShouldNotBeRemovedFromTheVault() {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
restoreAllMessagesOfHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
restoreAllMessagesOfHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(2));
}
@Test
void vaultEndpointShouldNotRestoreItemsWhenTheVaultIsEmpty() {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
restoreAllMessagesOfHomer();
awaitSearchUpToDate();
// No additional had been restored as the vault is empty
assertThat(listMessageIdsForAccount(homerCredential).size())
.isEqualTo(1);
}
@Test
void vaultEndpointShouldNotRestoreMessageForSharee() {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(bartCredential)).hasSize(1));
String messageId = listMessageIdsForAccount(homerCredential).get(0);
homerMovesTheMailInAnotherMailbox(messageId);
homerSharesHisMailboxWithBart();
bartDeletesMessages(ImmutableList.of(messageId));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
restoreMessagesFor(BART);
awaitSearchUpToDate();
// No additional had been restored for Bart as the vault is empty
assertThat(listMessageIdsForAccount(bartCredential).size())
.isEqualTo(1);
}
@Test
void vaultEndpointShouldRestoreMessageForSharer() {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageId = listMessageIdsForAccount(homerCredential).get(0);
homerMovesTheMailInAnotherMailbox(messageId);
homerSharesHisMailboxWithBart();
bartDeletesMessages(ImmutableList.of(messageId));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
restoreAllMessagesOfHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String newMessageId = listMessageIdsForAccount(homerCredential).get(0);
assertThat(getMessageContent(homerCredential, newMessageId)
.getString("methodResponses[0][1].list[0].subject")).isEqualTo(SUBJECT);
}
@Tag(BasicFeature.TAG)
@Test
void vaultExportShouldExportZipContainsVaultMessagesToShareeWhenJmapDeleteMessage() throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageIdOfHomer = listMessageIdsForAccount(homerCredential).get(0);
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
String fileLocation = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasEntriesSize(1)
.allSatisfies(entry -> hasName(messageIdOfHomer + ".eml"));
}
}
@Tag(BasicFeature.TAG)
@Test
void vaultExportShouldExportZipContainsVaultMessagesToShareeWhenImapDeleteMessage(GuiceJamesServer jmapServer) throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageIdOfHomer = listMessageIdsForAccount(homerCredential).get(0);
testIMAPClient.connect(LOCALHOST_IP, jmapServer.getProbe(ImapGuiceProbe.class).getImapPort())
.login(HOMER, PASSWORD)
.select(TestIMAPClient.INBOX)
.setFlagsForAllMessagesInMailbox("\\Deleted");
testIMAPClient.expunge();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
String fileLocation = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasEntriesSize(1)
.allSatisfies(entry -> hasName(messageIdOfHomer + ".eml"));
}
}
@Tag(BasicFeature.TAG)
@Test
public void vaultExportShouldExportZipContainsVaultMessagesToShareeWhenImapDeletedMailbox(GuiceJamesServer jmapServer) throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageIdOfHomer = listMessageIdsForAccount(homerCredential).get(0);
testIMAPClient.connect(LOCALHOST_IP, jmapServer.getProbe(ImapGuiceProbe.class).getImapPort())
.login(HOMER, PASSWORD)
.select(TestIMAPClient.INBOX);
testIMAPClient.moveFirstMessage(MAILBOX_NAME);
testIMAPClient.delete(MAILBOX_NAME);
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
String fileLocation = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasEntriesSize(1)
.allSatisfies(entry -> hasName(messageIdOfHomer + ".eml"));
}
}
@Test
void vaultExportShouldExportZipContainsOnlyMatchedMessages() throws Exception {
bartSendMessageToHomerWithSubject(FIRST_SUBJECT);
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String firstMessageIdOfHomer = listMessageIdsForAccount(homerCredential).get(0);
bartSendMessageToHomerWithSubject(SECOND_SUBJECT);
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(2));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
ExportRequest exportRequest = ExportRequest
.userExportFrom(HOMER)
.exportTo(BART)
.query("""
{
"fieldName": "subject",
"operator": "equals",
"value": "%s"
}
""".formatted(FIRST_SUBJECT));
String fileLocation = exportAndGetFileLocationFromLastMail(exportRequest, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.containsOnlyEntriesMatching(hasName(firstMessageIdOfHomer + ".eml"));
}
}
@Test
void vaultExportShouldExportEmptyZipWhenQueryDoesntMatch() throws Exception {
bartSendMessageToHomerWithSubject(FIRST_SUBJECT);
bartSendMessageToHomerWithSubject(SECOND_SUBJECT);
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(2));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
ExportRequest exportRequest = ExportRequest
.userExportFrom(HOMER)
.exportTo(BART)
.query("{" +
" \"fieldName\": \"subject\"," +
" \"operator\": \"equals\"," +
" \"value\": \"non matching\"" +
"}");
String fileLocation = exportAndGetFileLocationFromLastMail(exportRequest, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasNoEntry();
}
}
@Test
void vaultExportShouldExportEmptyZipWhenVaultIsEmpty() throws Exception {
String fileLocation = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasNoEntry();
}
}
@Test
void vaultExportShouldResponseIdempotentSideEffect() throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
String fileLocationFirstExport = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
String fileLocationSecondExport = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocationFirstExport))) {
zipAssert.hasSameContentWith(new FileInputStream(fileLocationSecondExport));
}
}
@Test
void vaultPurgeShouldMakeExportProduceEmptyZipWhenAllMessagesAreExpired(UpdatableTickingClock clock) throws Exception {
bartSendMessageToHomer();
bartSendMessageToHomer();
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(3));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
clock.setInstant(TWO_MONTH_AFTER_ONE_YEAR_EXPIRATION.toInstant());
purgeVault(webAdminApi);
String fileLocation = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasNoEntry();
}
}
@Test
void vaultPurgeShouldMakeExportProduceAZipWhenOneMessageIsNotExpired(UpdatableTickingClock clock) throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageIdOfNotExpiredMessage = listMessageIdsForAccount(homerCredential).get(0);
clock.setInstant(TWO_MONTH_AFTER_ONE_YEAR_EXPIRATION.toInstant());
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
purgeVault(webAdminApi);
String fileLocation = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasEntriesSize(1)
.allSatisfies(entry -> hasName(messageIdOfNotExpiredMessage + ".eml"));
}
}
@Test
void vaultPurgeShouldMakeExportProduceZipWhenAllMessagesAreNotExpired() throws Exception {
bartSendMessageToHomer();
bartSendMessageToHomer();
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(3));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
purgeVault(webAdminApi);
String fileLocation = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasEntriesSize(3);
}
}
@Test
void vaultPurgeShouldNotAppendMessageToTheUserMailbox(UpdatableTickingClock clock) {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
clock.setInstant(TWO_MONTH_AFTER_ONE_YEAR_EXPIRATION.toInstant());
purgeVault(webAdminApi);
assertThat(listMessageIdsForAccount(homerCredential))
.hasSize(0);
}
@Test
void vaultDeleteShouldDeleteMessageThenExportWithNoEntry() throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageIdOfHomer = listMessageIdsForAccount(homerCredential).get(0);
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
deleteFromVault(webAdminApi, HOMER, messageIdOfHomer);
String fileLocation = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasNoEntry();
}
}
@Test
void vaultDeleteShouldNotDeleteEmptyVaultThenExportNoEntry() throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageIdOfHomer = listMessageIdsForAccount(homerCredential).get(0);
deleteFromVault(webAdminApi, HOMER, messageIdOfHomer);
String fileLocation = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasNoEntry();
}
}
@Test
void vaultDeleteShouldNotDeleteNotMatchedMessageInVaultThenExportAnEntry() throws Exception {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageIdOfHomer = listMessageIdsForAccount(homerCredential).get(0);
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(bartCredential)).hasSize(1));
String messageIdOfBart = listMessageIdsForAccount(bartCredential).get(0);
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
deleteFromVault(webAdminApi, HOMER, messageIdOfBart);
String fileLocation = exportAndGetFileLocationFromLastMail(EXPORT_ALL_HOMER_MESSAGES_TO_BART, bartCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocation))) {
zipAssert.hasEntriesSize(1)
.allSatisfies(entry -> hasName(messageIdOfHomer + ".eml"));
}
}
@Test
void vaultDeleteShouldNotAppendMessageToTheUserMailbox() {
bartSendMessageToHomer();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String messageIdOfHomer = listMessageIdsForAccount(homerCredential).get(0);
homerDeletesMessages(listMessageIdsForAccount(homerCredential));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
deleteFromVault(webAdminApi, HOMER, messageIdOfHomer);
assertThat(listMessageIdsForAccount(homerCredential))
.hasSize(0);
}
@Test
void vaultDeleteShouldDeleteAllMessagesHavingSameBlobContent() throws Exception {
bartSendMessageToHomerAndJack();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String homerInboxMessageId = listMessageIdsForAccount(homerCredential).get(0);
homerDeletesMessages(ImmutableList.of(homerInboxMessageId));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
// the message same with homer's one in inbox
String jackInboxMessageId = listMessageIdsForAccount(jackCredential).get(0);
jackDeletesMessages(ImmutableList.of(jackInboxMessageId));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(jackCredential)).hasSize(0));
// delete from homer's vault, expecting the message contains the same blob in jack's vault will be deleted
deleteFromVault(webAdminApi, HOMER, homerInboxMessageId);
String fileLocationOfBartMessages = exportAndGetFileLocationFromLastMail(EXPORT_ALL_JACK_MESSAGES_TO_HOMER, homerCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocationOfBartMessages))) {
zipAssert.hasNoEntry();
}
}
@Test
void vaultDeleteShouldNotDeleteAllMessagesHavingSameBlobContentWhenMessageNotDeletedWithinTheSameMonth(UpdatableTickingClock clock) throws Exception {
bartSendMessageToHomerAndJack();
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(1));
String homerInboxMessageId = listMessageIdsForAccount(homerCredential).get(0);
homerDeletesMessages(ImmutableList.of(homerInboxMessageId));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(homerCredential)).hasSize(0));
// one year later, delete jack's message
clock.setInstant(NOW.plusYears(1).toInstant());
// the message same with homer's one in inbox
String jackInboxMessageId = listMessageIdsForAccount(jackCredential).get(0);
jackDeletesMessages(ImmutableList.of(jackInboxMessageId));
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(jackCredential)).hasSize(0));
// delete from homer's vault, expecting jack's vault still be intact
deleteFromVault(webAdminApi, HOMER, homerInboxMessageId);
String fileLocationOfBartMessages = exportAndGetFileLocationFromLastMail(EXPORT_ALL_JACK_MESSAGES_TO_HOMER, homerCredential);
try (ZipAssert zipAssert = assertThatZip(new FileInputStream(fileLocationOfBartMessages))) {
zipAssert.hasEntriesSize(1)
.allSatisfies(entry -> hasName(jackInboxMessageId + ".eml"));
}
}
private String exportAndGetFileLocationFromLastMail(ExportRequest exportRequest, UserCredential shareeCredential) {
int currentNumberOfMessages = listMessageIdsForAccount(shareeCredential).size();
exportVaultContent(webAdminApi, exportRequest);
WAIT_TWO_MINUTES.untilAsserted(() -> assertThat(listMessageIdsForAccount(shareeCredential)).hasSize(currentNumberOfMessages + 1));
String exportingMessageId = getLatestMessageId(shareeCredential, Role.INBOX);
return exportedFileLocationFromMailHeader(exportingMessageId, shareeCredential);
}
private String exportedFileLocationFromMailHeader(String messageId, UserCredential userCredential) {
List<Map<String, String>> headers = with()
.auth().basic(userCredential.username().asString(), userCredential.password())
.body("""
{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:submission"],
"methodCalls": [
["Email/get", {
"accountId": "%s",
"ids": ["%s"],
"properties":["bodyStructure"],
"bodyProperties":["name", "type","headers"]
}, "c2"]
]
}
""".formatted(userCredential.accountId(), messageId))
.post("/jmap")
.then()
.statusCode(200)
.contentType(JSON)
.extract()
.body()
.path("methodResponses[0][1].list[0].bodyStructure.headers");
return headers.stream()
.filter(header -> header.get("name").equals("corresponding-file"))
.findFirst()
.orElseThrow(() -> new RuntimeException("No corresponding-file header found"))
.get("value").trim();
}
private void homerSharesHisMailboxWithBart() {
with()
.auth().basic(homerCredential.username().asString(), homerCredential.password())
.body("""
{
"using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:apache:james:params:jmap:mail:shares" ],
"methodCalls": [
[
"Mailbox/set",
{
"accountId": "%s",
"update": {
"%s": {
"sharedWith": {
"%s":["r", "l", "w", "t"]
}
}
}
},
"c1"
]
]
}
""".formatted(homerCredential.accountId(), otherMailboxId.serialize(), BART))
.post("/jmap");
}
private void bartSendMessageToHomer() {
bartSendMessageToHomerWithSubject(SUBJECT);
}
private void bartSendMessageToHomerAndJack() {
String outboxId = getOutboxId(bartCredential);
String requestBody =
"{" +
" \"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"]," +
" \"methodCalls\": [" +
" [\"Email/set\", {" +
" \"accountId\": \"" + bartCredential.accountId() + "\"," +
" \"create\": {" +
" \"e1526\": {" +
" \"mailboxIds\": { \"" + outboxId + "\": true }," +
" \"subject\": \"" + SUBJECT + "\"," +
" \"htmlBody\": [{" +
" \"partId\": \"a49d\"," +
" \"type\": \"text/html\"" +
" }]," +
" \"bodyValues\": {" +
" \"a49d\": {" +
" \"value\": \"Test <b>body</b>, HTML version\"" +
" }" +
" }," +
" \"to\": [{" +
" \"email\": \"" + HOMER + "\"" +
" }, {" +
" \"email\": \"" + JACK + "\"" +
" }]," +
" \"from\": [{" +
" \"email\": \"" + BART + "\"" +
" }]" +
" }" +
" }" +
" }, \"c1\"]," +
" [\"Email/get\", {" +
" \"accountId\": \"" + bartCredential.accountId() + "\"," +
" \"ids\": [\"#e1526\"]," +
" \"properties\": [\"sentAt\"]" +
" }, \"c2\"]," +
" [\"EmailSubmission/set\", {" +
" \"accountId\": \"" + bartCredential.accountId() + "\"," +
" \"create\": {" +
" \"k1490\": {" +
" \"emailId\": \"#e1526\"," +
" \"envelope\": {" +
" \"mailFrom\": {\"email\": \"" + BART + "\"}," +
" \"rcptTo\": [{" +
" \"email\": \"" + HOMER + "\"" +
" }, {" +
" \"email\": \"" + JACK + "\"" +
" }]" +
" }" +
" }" +
" }" +
" }, \"c3\"]" +
" ]" +
"}";
with()
.auth().basic(bartCredential.username().asString(), bartCredential.password())
.body(requestBody)
.post("/jmap")
.then()
.statusCode(200)
.contentType(JSON)
.body("methodResponses[2][1].created", Matchers.is(notNullValue()));
}
private void bartSendMessageToHomerWithSubject(String subject) {
String outboxId = getOutboxId(bartCredential);
String requestBody =
"{" +
" \"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"]," +
" \"methodCalls\": [" +
" [\"Email/set\", {" +
" \"accountId\": \"" + bartCredential.accountId() + "\"," +
" \"create\": {" +
" \"e1526\": {" +
" \"mailboxIds\": { \"" + outboxId + "\": true }," +
" \"subject\": \"" + subject + "\"," +
" \"htmlBody\": [{" +
" \"partId\": \"a49d\"," +
" \"type\": \"text/html\"" +
" }]," +
" \"bodyValues\": {" +
" \"a49d\": {" +
" \"value\": \"Test <b>body</b>, HTML version\"" +
" }" +
" }," +
" \"to\": [{\"email\": \"" + HOMER + "\"}]," +
" \"from\": [{\"email\": \"" + BART + "\"}]" +
" }" +
" }" +
" }, \"c1\"]," +
" [\"Email/get\", {" +
" \"accountId\": \"" + bartCredential.accountId() + "\"," +
" \"ids\": [\"#e1526\"]," +
" \"properties\": [\"sentAt\"]" +
" }, \"c2\"]," +
" [\"EmailSubmission/set\", {" +
" \"accountId\": \"" + bartCredential.accountId() + "\"," +
" \"create\": {" +
" \"k1490\": {" +
" \"emailId\": \"#e1526\"," +
" \"envelope\": {" +
" \"mailFrom\": {\"email\": \"" + BART + "\"}," +
" \"rcptTo\": [{\"email\": \"" + HOMER + "\"}]" +
" }" +
" }" +
" }" +
" }, \"c3\"]" +
" ]" +
"}";
with()
.auth().basic(bartCredential.username().asString(), bartCredential.password())
.body(requestBody)
.post("/jmap")
.then()
.statusCode(200)
.contentType(JSON)
.body("methodResponses[2][1].created", Matchers.is(notNullValue()));
}
private void homerDeletesMessages(List<String> idsToDestroy) {
deleteMessages(homerCredential, idsToDestroy);
// Grace period for the vault
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
private void bartDeletesMessages(List<String> idsToDestroy) {
deleteMessages(bartCredential, idsToDestroy);
}
private void jackDeletesMessages(List<String> idsToDestroy) {
deleteMessages(jackCredential, idsToDestroy);
}
private void restoreAllMessagesOfHomer() {
restoreMessagesFor(HOMER);
}
private void restoreMessagesFor(String user) {
restoreMessagesForUserWithQuery(webAdminApi, user, MATCH_ALL_QUERY);
}
private void homerMovesTheMailInAnotherMailbox(String messageId) {
given()
.auth().basic(homerCredential.username().asString(), homerCredential.password())
.body("""
{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Email/set", {
"accountId": "%s",
"update": {
"%s":{
"mailboxIds": { "%s" : true}
}
}
}, "c1"]]
}""".formatted(homerCredential.accountId(), messageId, otherMailboxId.serialize()))
.when()
.post("/jmap")
.then()
.statusCode(200)
.contentType(JSON);
}
private boolean homerHasMailboxWithRole(Role role) {
return getAllMailboxesIds(homerCredential).stream()
.filter(mailbox -> mailbox.get("role") != null)
.anyMatch(mailbox -> mailbox.get("role").equals(role.serialize())
&& mailbox.get("name").equals(role.getDefaultMailbox()));
}
}