JAMES-2266 Integration test when fixing ghost mailbox bug
Project: http://git-wip-us.apache.org/repos/asf/james-project/repo Commit: http://git-wip-us.apache.org/repos/asf/james-project/commit/8da3ad0b Tree: http://git-wip-us.apache.org/repos/asf/james-project/tree/8da3ad0b Diff: http://git-wip-us.apache.org/repos/asf/james-project/diff/8da3ad0b Branch: refs/heads/master Commit: 8da3ad0b20773b30b38483b7e71892dece37907b Parents: fde3336 Author: benwa <[email protected]> Authored: Wed Dec 27 17:03:18 2017 +0700 Committer: benwa <[email protected]> Committed: Fri Jan 5 16:06:36 2018 +0700 ---------------------------------------------------------------------- .../modules/mailbox/CassandraSessionModule.java | 4 + .../mailbox/ResilientClusterProvider.java | 3 +- .../org/apache/james/server/CassandraProbe.java | 40 +++ .../apache/james/FixingGhostMailboxTest.java | 337 +++++++++++++++++++ 4 files changed, 383 insertions(+), 1 deletion(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/james-project/blob/8da3ad0b/server/container/guice/cassandra-guice/src/main/java/org/apache/james/modules/mailbox/CassandraSessionModule.java ---------------------------------------------------------------------- diff --git a/server/container/guice/cassandra-guice/src/main/java/org/apache/james/modules/mailbox/CassandraSessionModule.java b/server/container/guice/cassandra-guice/src/main/java/org/apache/james/modules/mailbox/CassandraSessionModule.java index 84a33db..523cbc8 100644 --- a/server/container/guice/cassandra-guice/src/main/java/org/apache/james/modules/mailbox/CassandraSessionModule.java +++ b/server/container/guice/cassandra-guice/src/main/java/org/apache/james/modules/mailbox/CassandraSessionModule.java @@ -39,7 +39,9 @@ import org.apache.james.backends.cassandra.versions.CassandraSchemaVersionManage import org.apache.james.backends.cassandra.versions.CassandraSchemaVersionModule; import org.apache.james.lifecycle.api.Configurable; import org.apache.james.mailbox.store.BatchSizes; +import org.apache.james.server.CassandraProbe; import org.apache.james.utils.ConfigurationPerformer; +import org.apache.james.utils.GuiceProbe; import org.apache.james.utils.PropertiesProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -91,6 +93,8 @@ public class CassandraSessionModule extends AbstractModule { bind(CassandraSchemaVersionDAO.class).in(Scopes.SINGLETON); Multibinder.newSetBinder(binder(), ConfigurationPerformer.class).addBinding().to(CassandraSchemaChecker.class); + + Multibinder.newSetBinder(binder(), GuiceProbe.class).addBinding().to(CassandraProbe.class); } @Provides http://git-wip-us.apache.org/repos/asf/james-project/blob/8da3ad0b/server/container/guice/cassandra-guice/src/main/java/org/apache/james/modules/mailbox/ResilientClusterProvider.java ---------------------------------------------------------------------- diff --git a/server/container/guice/cassandra-guice/src/main/java/org/apache/james/modules/mailbox/ResilientClusterProvider.java b/server/container/guice/cassandra-guice/src/main/java/org/apache/james/modules/mailbox/ResilientClusterProvider.java index 93adf63..56820ec 100644 --- a/server/container/guice/cassandra-guice/src/main/java/org/apache/james/modules/mailbox/ResilientClusterProvider.java +++ b/server/container/guice/cassandra-guice/src/main/java/org/apache/james/modules/mailbox/ResilientClusterProvider.java @@ -55,6 +55,7 @@ import com.nurkiewicz.asyncretry.function.RetryCallable; @Singleton public class ResilientClusterProvider implements Provider<Cluster> { + public static final String CASSANDRA_KEYSPACE = "cassandra.keyspace"; private static final int DEFAULT_CONNECTION_MAX_RETRIES = 10; private static final int DEFAULT_CONNECTION_MIN_DELAY = 5000; private static final long CASSANDRA_HIGHEST_TRACKABLE_LATENCY_MILLIS = TimeUnit.SECONDS.toMillis(10); @@ -96,7 +97,7 @@ public class ResilientClusterProvider implements Provider<Cluster> { try { return ClusterWithKeyspaceCreatedFactory .config(cluster, - configuration.getString("cassandra.keyspace", DEFAULT_KEYSPACE)) + configuration.getString(CASSANDRA_KEYSPACE, DEFAULT_KEYSPACE)) .replicationFactor(configuration.getInt("cassandra.replication.factor", DEFAULT_REPLICATION_FACTOR)) .clusterWithInitializedKeyspace(); } catch (Exception e) { http://git-wip-us.apache.org/repos/asf/james-project/blob/8da3ad0b/server/container/guice/cassandra-guice/src/main/java/org/apache/james/server/CassandraProbe.java ---------------------------------------------------------------------- diff --git a/server/container/guice/cassandra-guice/src/main/java/org/apache/james/server/CassandraProbe.java b/server/container/guice/cassandra-guice/src/main/java/org/apache/james/server/CassandraProbe.java new file mode 100644 index 0000000..ff03013 --- /dev/null +++ b/server/container/guice/cassandra-guice/src/main/java/org/apache/james/server/CassandraProbe.java @@ -0,0 +1,40 @@ +/**************************************************************** + * 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.server; + +import javax.inject.Inject; + +import org.apache.commons.configuration.ConfigurationException; +import org.apache.james.backends.cassandra.init.CassandraSessionConfiguration; +import org.apache.james.modules.mailbox.ResilientClusterProvider; +import org.apache.james.utils.GuiceProbe; + +public class CassandraProbe implements GuiceProbe { + private final CassandraSessionConfiguration configuration; + + @Inject + public CassandraProbe(CassandraSessionConfiguration configuration) { + this.configuration = configuration; + } + + public String getKeyspace() throws ConfigurationException { + return configuration.getConfiguration().getString(ResilientClusterProvider.CASSANDRA_KEYSPACE); + } +} http://git-wip-us.apache.org/repos/asf/james-project/blob/8da3ad0b/server/container/guice/cassandra-guice/src/test/java/org/apache/james/FixingGhostMailboxTest.java ---------------------------------------------------------------------- diff --git a/server/container/guice/cassandra-guice/src/test/java/org/apache/james/FixingGhostMailboxTest.java b/server/container/guice/cassandra-guice/src/test/java/org/apache/james/FixingGhostMailboxTest.java new file mode 100644 index 0000000..62a4b30 --- /dev/null +++ b/server/container/guice/cassandra-guice/src/test/java/org/apache/james/FixingGhostMailboxTest.java @@ -0,0 +1,337 @@ +/**************************************************************** + * 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; + +import static com.datastax.driver.core.querybuilder.QueryBuilder.delete; +import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; +import static com.jayway.restassured.RestAssured.given; +import static com.jayway.restassured.RestAssured.with; +import static com.jayway.restassured.config.EncoderConfig.encoderConfig; +import static com.jayway.restassured.config.RestAssuredConfig.newConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.mail.Flags; + +import org.apache.http.client.utils.URIBuilder; +import org.apache.james.backends.cassandra.ContainerLifecycleConfiguration; +import org.apache.james.backends.cassandra.init.CassandraTypesProvider; +import org.apache.james.jmap.HttpJmapAuthentication; +import org.apache.james.jmap.api.access.AccessToken; +import org.apache.james.mailbox.cassandra.mail.task.MailboxMergingTask; +import org.apache.james.mailbox.cassandra.mail.utils.MailboxBaseTupleUtil; +import org.apache.james.mailbox.cassandra.modules.CassandraMailboxModule; +import org.apache.james.mailbox.cassandra.table.CassandraMailboxPathTable; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.ComposedMessageId; +import org.apache.james.mailbox.model.MailboxACL; +import org.apache.james.mailbox.model.MailboxConstants; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.store.mail.model.Mailbox; +import org.apache.james.mailbox.store.probe.ACLProbe; +import org.apache.james.mailbox.store.probe.MailboxProbe; +import org.apache.james.modules.ACLProbeImpl; +import org.apache.james.modules.MailboxProbeImpl; +import org.apache.james.probe.DataProbe; +import org.apache.james.server.CassandraProbe; +import org.apache.james.task.TaskManager; +import org.apache.james.utils.DataProbeImpl; +import org.apache.james.utils.JmapGuiceProbe; +import org.apache.james.utils.WebAdminGuiceProbe; +import org.apache.james.webadmin.RandomPortSupplier; +import org.apache.james.webadmin.WebAdminConfiguration; +import org.apache.james.webadmin.routes.CassandraMailboxMergingRoutes; +import org.apache.james.webadmin.routes.TasksRoutes; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; + +import com.datastax.driver.core.Cluster; +import com.datastax.driver.core.Session; +import com.google.common.base.Charsets; +import com.jayway.restassured.RestAssured; +import com.jayway.restassured.builder.RequestSpecBuilder; +import com.jayway.restassured.http.ContentType; + +public class FixingGhostMailboxTest { + + private static final String NAME = "[0][0]"; + private static final String ARGUMENTS = "[0][1]"; + private static final String FIRST_MAILBOX = ARGUMENTS + ".list[0]"; + public static final boolean RECENT = true; + + @ClassRule + public static DockerCassandraRule cassandra = new DockerCassandraRule(); + + public static ContainerLifecycleConfiguration cassandraLifecycleConfiguration = ContainerLifecycleConfiguration.withDefaultIterationsBetweenRestart().container(cassandra.getRawContainer()).build(); + + @Rule + public CassandraJmapTestRule rule = CassandraJmapTestRule.defaultTestRule(); + + @Rule + public TestRule cassandraLifecycleTestRule = cassandraLifecycleConfiguration.asTestRule(); + + private AccessToken accessToken; + private String domain; + private String alice; + private String bob; + private String cedric; + private GuiceJamesServer jmapServer; + private MailboxProbe mailboxProbe; + private ACLProbe aclProbe; + private Session session; + private CassandraTypesProvider cassandraTypesProvider; + private MailboxBaseTupleUtil mailboxBaseTupleUtil; + private ComposedMessageId message1; + private MailboxId aliceGhostInboxId; + private MailboxPath aliceInboxPath; + private ComposedMessageId message2; + private WebAdminGuiceProbe webAdminProbe; + + @Before + public void setup() throws Throwable { + jmapServer = rule.jmapServer(cassandra.getModule(), + binder -> binder.bind(WebAdminConfiguration.class) + .toInstance(WebAdminConfiguration.builder() + .port(new RandomPortSupplier()) + .enabled() + .build())); + jmapServer.start(); + webAdminProbe = jmapServer.getProbe(WebAdminGuiceProbe.class); + mailboxProbe = jmapServer.getProbe(MailboxProbeImpl.class); + aclProbe = jmapServer.getProbe(ACLProbeImpl.class); + + RestAssured.requestSpecification = new RequestSpecBuilder() + .setContentType(ContentType.JSON) + .setAccept(ContentType.JSON) + .setConfig(newConfig().encoderConfig(encoderConfig().defaultContentCharset(Charsets.UTF_8))) + .setPort(jmapServer.getProbe(JmapGuiceProbe.class).getJmapPort()) + .build(); + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + + domain = "domain.tld"; + alice = "alice@" + domain; + String alicePassword = "aliceSecret"; + bob = "bob@" + domain; + cedric = "cedric@" + domain; + DataProbe dataProbe = jmapServer.getProbe(DataProbeImpl.class); + dataProbe.addDomain(domain); + dataProbe.addUser(alice, alicePassword); + dataProbe.addUser(bob, "bobSecret"); + accessToken = HttpJmapAuthentication.authenticateJamesUser(baseUri(), alice, alicePassword); + + session = Cluster.builder() + .addContactPoint(cassandra.getIp()) + .withPort(cassandra.getMappedPort(9042)) + .build() + .connect(jmapServer.getProbe(CassandraProbe.class).getKeyspace()); + cassandraTypesProvider = new CassandraTypesProvider(new CassandraMailboxModule(), session); + mailboxBaseTupleUtil = new MailboxBaseTupleUtil(cassandraTypesProvider); + + simulateGhostMailboxBug(); + } + + private void simulateGhostMailboxBug() throws MailboxException { + // State before ghost mailbox bug + // Alice INBOX is delegated to Bob and contains one message + aliceInboxPath = MailboxPath.forUser(alice, MailboxConstants.INBOX); + aliceGhostInboxId = mailboxProbe.createMailbox(MailboxConstants.USER_NAMESPACE, alice, MailboxConstants.INBOX); + aclProbe.addRights(aliceInboxPath, bob, MailboxACL.FULL_RIGHTS); + message1 = mailboxProbe.appendMessage(alice, aliceInboxPath, + generateMessageContent(), new Date(), !RECENT, new Flags()); + rule.await(); + + // Simulate ghost mailbox bug + session.execute(delete().from(CassandraMailboxPathTable.TABLE_NAME) + .where(eq(CassandraMailboxPathTable.NAMESPACE_AND_USER, mailboxBaseTupleUtil.createMailboxBaseUDT(MailboxConstants.USER_NAMESPACE, alice))) + .and(eq(CassandraMailboxPathTable.MAILBOX_NAME, MailboxConstants.INBOX))); + + // trigger provisioning + given() + .header("Authorization", accessToken.serialize()) + .body("[[\"getMailboxes\", {}, \"#0\"]]") + .when() + .post("/jmap") + .then() + .statusCode(200); + + // Received a new message + message2 = mailboxProbe.appendMessage(alice, aliceInboxPath, + generateMessageContent(), new Date(), !RECENT, new Flags()); + rule.await(); + } + + private ByteArrayInputStream generateMessageContent() { + return new ByteArrayInputStream("Subject: toto\r\n\r\ncontent".getBytes(StandardCharsets.UTF_8)); + } + + private URIBuilder baseUri() { + return new URIBuilder() + .setScheme("http") + .setHost("localhost") + .setPort(jmapServer.getProbe(JmapGuiceProbe.class) + .getJmapPort()) + .setCharset(Charsets.UTF_8); + } + + @After + public void teardown() { + jmapServer.stop(); + } + + @Test + public void ghostMailboxBugShouldChangeMailboxId() throws Exception { + Mailbox newAliceInbox = mailboxProbe.getMailbox(MailboxConstants.USER_NAMESPACE, alice, MailboxConstants.INBOX); + + assertThat(aliceGhostInboxId).isNotEqualTo(newAliceInbox.getMailboxId()); + } + + @Test + public void ghostMailboxBugShouldDiscardOldContent() throws Exception { + Mailbox newAliceInbox = mailboxProbe.getMailbox(MailboxConstants.USER_NAMESPACE, alice, MailboxConstants.INBOX); + + given() + .header("Authorization", accessToken.serialize()) + .body("[[\"getMessageList\", {\"filter\":{\"inMailboxes\":[\"" + newAliceInbox.getMailboxId().serialize() + "\"]}}, \"#0\"]]") + .when() + .post("/jmap") + .then() + .statusCode(200) + .body(NAME, equalTo("messageList")) + .body(ARGUMENTS + ".messageIds", hasSize(1)) + .body(ARGUMENTS + ".messageIds", not(contains(message1.getMessageId().serialize()))) + .body(ARGUMENTS + ".messageIds", contains(message2.getMessageId().serialize())); + } + + @Test + public void webadminCanMergeTwoMailboxes() throws Exception { + Mailbox newAliceInbox = mailboxProbe.getMailbox(MailboxConstants.USER_NAMESPACE, alice, MailboxConstants.INBOX); + + fixGhostMailboxes(newAliceInbox); + + given() + .header("Authorization", accessToken.serialize()) + .body("[[\"getMessageList\", {\"filter\":{\"inMailboxes\":[\"" + newAliceInbox.getMailboxId().serialize() + "\"]}}, \"#0\"]]") + .when() + .post("/jmap") + .then() + .statusCode(200) + .body(NAME, equalTo("messageList")) + .body(ARGUMENTS + ".messageIds", hasSize(2)) + .body(ARGUMENTS + ".messageIds", containsInAnyOrder( + message1.getMessageId().serialize(), + message2.getMessageId().serialize())); + } + + @Test + public void webadminCanMergeTwoMailboxesRights() throws Exception { + Mailbox newAliceInbox = mailboxProbe.getMailbox(MailboxConstants.USER_NAMESPACE, alice, MailboxConstants.INBOX); + aclProbe.addRights(aliceInboxPath, cedric, MailboxACL.FULL_RIGHTS); + + fixGhostMailboxes(newAliceInbox); + + given() + .header("Authorization", accessToken.serialize()) + .body("[[\"getMailboxes\", {\"ids\": [\"" + newAliceInbox.getMailboxId().serialize() + "\"]}, \"#0\"]]") + .when() + .post("/jmap") + .then() + .statusCode(200) + .body(NAME, equalTo("mailboxes")) + .body(FIRST_MAILBOX + ".sharedWith", hasKey(bob)) + .body(FIRST_MAILBOX + ".sharedWith", hasKey(cedric)); + } + + @Test + public void oldGhostedMailboxShouldNoMoreBeAccessible() throws Exception { + Mailbox newAliceInbox = mailboxProbe.getMailbox(MailboxConstants.USER_NAMESPACE, alice, MailboxConstants.INBOX); + aclProbe.addRights(aliceInboxPath, cedric, MailboxACL.FULL_RIGHTS); + + fixGhostMailboxes(newAliceInbox); + + given() + .header("Authorization", accessToken.serialize()) + .body("[[\"getMailboxes\", {\"ids\": [\"" + aliceGhostInboxId.serialize() + "\"]}, \"#0\"]]") + .when() + .post("/jmap") + .then() + .statusCode(200) + .body(NAME, equalTo("mailboxes")) + .body(ARGUMENTS + ".list", hasSize(0)); + } + + @Test + public void mergingMailboxTaskShouldBeInformative() { + Mailbox newAliceInbox = mailboxProbe.getMailbox(MailboxConstants.USER_NAMESPACE, alice, MailboxConstants.INBOX); + + String taskId = fixGhostMailboxes(newAliceInbox); + + given() + .port(webAdminProbe.getWebAdminPort()) + .basePath(TasksRoutes.BASE) + .when() + .get(taskId + "/await") + .then() + .body("status", is(TaskManager.Status.COMPLETED.getValue())) + .body("taskId", is(taskId)) + .body("additionalInformation.oldMailboxId", is(aliceGhostInboxId.serialize())) + .body("additionalInformation.newMailboxId", is(newAliceInbox.getMailboxId().serialize())) + .body("type", is(MailboxMergingTask.MAILBOX_MERGING)) + .body("submitDate", is(not(nullValue()))) + .body("startedDate", is(not(nullValue()))) + .body("completedDate", is(not(nullValue()))); + + } + + private String fixGhostMailboxes(Mailbox newAliceInbox) { + String taskId = with() + .port(webAdminProbe.getWebAdminPort()) + .basePath(CassandraMailboxMergingRoutes.BASE) + .body("{" + + " \"mergeOrigin\":\"" + aliceGhostInboxId.serialize() + "\"," + + " \"mergeDestination\":\"" + newAliceInbox.getMailboxId().serialize() + "\"" + + "}") + .post() + .jsonPath() + .getString("taskId"); + with() + .port(webAdminProbe.getWebAdminPort()) + .basePath(TasksRoutes.BASE) + .get(taskId + "/await"); + rule.await(); + return taskId; + } + +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
