You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2021/11/08 01:54:07 UTC

[james-project] branch master updated: JAMES-3539 JMAP webpush integration test (#736)

This is an automated email from the ASF dual-hosted git repository.

btellier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git


The following commit(s) were added to refs/heads/master by this push:
     new 7ef52c9  JAMES-3539 JMAP webpush integration test (#736)
7ef52c9 is described below

commit 7ef52c9fbb5dbe69b1d0d24d6d92c4c71e2c748a
Author: vttran <vt...@linagora.com>
AuthorDate: Mon Nov 8 08:53:59 2021 +0700

    JAMES-3539 JMAP webpush integration test (#736)
---
 .../java/org/apache/james/ClockExtension.java}     |  37 +-
 .../james/modules/event/JMAPEventBusModule.java    |   8 +-
 .../modules/protocols/JmapEventBusModule.java      |   4 +
 .../distributed/DistributedWebPushTest.java        |  63 +++
 .../src/test/resources/smtpserver.xml              |  23 +
 .../jmap-rfc-8621-integration-tests-common/pom.xml |   8 +
 .../jmap/rfc8621/contract/WebPushContract.scala    | 537 +++++++++++++++++++++
 .../jmap/rfc8621/memory/MemoryWebPushTest.java}    |  30 +-
 8 files changed, 690 insertions(+), 20 deletions(-)

diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/modules/protocols/JmapEventBusModule.java b/server/container/guice/common/src/test/java/org/apache/james/ClockExtension.java
similarity index 51%
copy from server/container/guice/protocols/jmap/src/main/java/org/apache/james/modules/protocols/JmapEventBusModule.java
copy to server/container/guice/common/src/test/java/org/apache/james/ClockExtension.java
index 0fafb26..0d09cf4 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/modules/protocols/JmapEventBusModule.java
+++ b/server/container/guice/common/src/test/java/org/apache/james/ClockExtension.java
@@ -17,17 +17,38 @@
  * under the License.                                           *
  ****************************************************************/
 
-package org.apache.james.modules.protocols;
+package org.apache.james;
 
-import org.apache.james.events.EventBus;
-import org.apache.james.jmap.InjectionKeys;
+import java.time.Clock;
+import java.time.Instant;
 
-import com.google.inject.AbstractModule;
-import com.google.inject.name.Names;
+import org.apache.james.utils.UpdatableTickingClock;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ParameterContext;
+import org.junit.jupiter.api.extension.ParameterResolutionException;
+
+import com.google.inject.Module;
+
+public class ClockExtension implements GuiceModuleTestExtension {
+    private UpdatableTickingClock clock;
+
+    @Override
+    public void beforeEach(ExtensionContext extensionContext) {
+        clock = new UpdatableTickingClock(Instant.now());
+    }
+
+    @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;
+    }
 
-public class JmapEventBusModule extends AbstractModule {
     @Override
-    protected void configure() {
-        bind(EventBus.class).annotatedWith(Names.named(InjectionKeys.JMAP)).to(EventBus.class);
+    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
+        return clock;
     }
 }
diff --git a/server/container/guice/distributed/src/main/java/org/apache/james/modules/event/JMAPEventBusModule.java b/server/container/guice/distributed/src/main/java/org/apache/james/modules/event/JMAPEventBusModule.java
index 62ca4f9..62a34bf 100644
--- a/server/container/guice/distributed/src/main/java/org/apache/james/modules/event/JMAPEventBusModule.java
+++ b/server/container/guice/distributed/src/main/java/org/apache/james/modules/event/JMAPEventBusModule.java
@@ -37,6 +37,7 @@ import org.apache.james.events.RoutingKeyConverter;
 import org.apache.james.jmap.InjectionKeys;
 import org.apache.james.jmap.change.Factory;
 import org.apache.james.jmap.change.JmapEventSerializer;
+import org.apache.james.jmap.pushsubscription.PushListener;
 import org.apache.james.metrics.api.MetricFactory;
 import org.apache.james.utils.InitializationOperation;
 import org.apache.james.utils.InitilizationOperationBuilder;
@@ -59,10 +60,13 @@ public class JMAPEventBusModule extends AbstractModule {
     }
 
     @ProvidesIntoSet
-    InitializationOperation workQueue(@Named(InjectionKeys.JMAP) RabbitMQEventBus instance) {
+    InitializationOperation workQueue(@Named(InjectionKeys.JMAP) RabbitMQEventBus instance, PushListener pushListener) {
         return InitilizationOperationBuilder
             .forClass(RabbitMQEventBus.class)
-            .init(instance::start);
+            .init(() -> {
+                instance.start();
+                instance.register(pushListener);
+            });
     }
 
     @ProvidesIntoSet
diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/modules/protocols/JmapEventBusModule.java b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/modules/protocols/JmapEventBusModule.java
index 0fafb26..71bdb0d 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/modules/protocols/JmapEventBusModule.java
+++ b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/modules/protocols/JmapEventBusModule.java
@@ -20,14 +20,18 @@
 package org.apache.james.modules.protocols;
 
 import org.apache.james.events.EventBus;
+import org.apache.james.events.EventListener;
 import org.apache.james.jmap.InjectionKeys;
+import org.apache.james.jmap.pushsubscription.PushListener;
 
 import com.google.inject.AbstractModule;
+import com.google.inject.multibindings.Multibinder;
 import com.google.inject.name.Names;
 
 public class JmapEventBusModule extends AbstractModule {
     @Override
     protected void configure() {
         bind(EventBus.class).annotatedWith(Names.named(InjectionKeys.JMAP)).to(EventBus.class);
+        Multibinder.newSetBinder(binder(), EventListener.ReactiveGroupEventListener.class).addBinding().to(PushListener.class);
     }
 }
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedWebPushTest.java b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedWebPushTest.java
new file mode 100644
index 0000000..8a9a2ae
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedWebPushTest.java
@@ -0,0 +1,63 @@
+/****************************************************************
+ * 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.jmap.rfc8621.distributed;
+
+import org.apache.james.CassandraExtension;
+import org.apache.james.CassandraRabbitMQJamesConfiguration;
+import org.apache.james.CassandraRabbitMQJamesServerMain;
+import org.apache.james.ClockExtension;
+import org.apache.james.DockerElasticSearchExtension;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.jmap.rfc8621.contract.PushServerExtension;
+import org.apache.james.jmap.rfc8621.contract.PushSubscriptionProbeModule;
+import org.apache.james.jmap.rfc8621.contract.WebPushContract;
+import org.apache.james.modules.AwsS3BlobStoreExtension;
+import org.apache.james.modules.RabbitMQExtension;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.apache.james.modules.blobstore.BlobStoreConfiguration;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+class DistributedWebPushTest implements WebPushContract {
+    public static final DockerElasticSearchExtension ELASTIC_SEARCH_EXTENSION = new DockerElasticSearchExtension();
+
+    @RegisterExtension
+    static JamesServerExtension testExtension = new JamesServerBuilder<CassandraRabbitMQJamesConfiguration>(tmpDir ->
+        CassandraRabbitMQJamesConfiguration.builder()
+            .workingDirectory(tmpDir)
+            .configurationFromClasspath()
+            .blobStore(BlobStoreConfiguration.builder()
+                .s3()
+                .disableCache()
+                .deduplication()
+                .noCryptoConfig())
+            .build())
+        .extension(ELASTIC_SEARCH_EXTENSION)
+        .extension(new CassandraExtension())
+        .extension(new RabbitMQExtension())
+        .extension(new AwsS3BlobStoreExtension())
+        .extension(new ClockExtension())
+        .server(configuration -> CassandraRabbitMQJamesServerMain.createServer(configuration)
+            .overrideWith(new TestJMAPServerModule(), new PushSubscriptionProbeModule()))
+        .build();
+
+    @RegisterExtension
+    static PushServerExtension pushServerExtension = new PushServerExtension();
+}
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/resources/smtpserver.xml b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/resources/smtpserver.xml
index 3c9ee85..d62267a 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/resources/smtpserver.xml
+++ b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/resources/smtpserver.xml
@@ -20,6 +20,29 @@
  -->
 
 <smtpservers>
+    <smtpserver enabled="true">
+        <jmxName>smtpserver-global</jmxName>
+        <bind>0.0.0.0:0</bind>
+        <connectionBacklog>200</connectionBacklog>
+        <tls socketTLS="false" startTLS="false">
+            <keystore>file://conf/keystore</keystore>
+            <secret>james72laBalle</secret>
+            <provider>org.bouncycastle.jce.provider.BouncyCastleProvider</provider>
+            <algorithm>SunX509</algorithm>
+        </tls>
+        <connectiontimeout>360</connectiontimeout>
+        <connectionLimit>0</connectionLimit>
+        <connectionLimitPerIP>0</connectionLimitPerIP>
+        <authRequired>false</authRequired>
+        <verifyIdentity>false</verifyIdentity>
+        <maxmessagesize>0</maxmessagesize>
+        <addressBracketsEnforcement>true</addressBracketsEnforcement>
+        <smtpGreeting>Apache JAMES awesome SMTP Server</smtpGreeting>
+        <handlerchain>
+            <handler class="org.apache.james.smtpserver.fastfail.ValidRcptHandler"/>
+            <handler class="org.apache.james.smtpserver.CoreCmdHandlerLoader"/>
+        </handlerchain>
+    </smtpserver>
 </smtpservers>
 
 
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/pom.xml b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/pom.xml
index c3eba74..16e7079 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/pom.xml
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/pom.xml
@@ -48,10 +48,18 @@
         </dependency>
         <dependency>
             <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-guice-smtp</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
             <artifactId>james-server-jmap-draft</artifactId>
         </dependency>
         <dependency>
             <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-testing</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
             <artifactId>testing-base</artifactId>
         </dependency>
         <dependency>
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushContract.scala
new file mode 100644
index 0000000..33b9ef4
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushContract.scala
@@ -0,0 +1,537 @@
+/****************************************************************
+ * 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.jmap.rfc8621.contract
+
+import com.google.crypto.tink.apps.webpush.WebPushHybridDecrypt
+import com.google.crypto.tink.subtle.EllipticCurves
+import com.google.crypto.tink.subtle.EllipticCurves.CurveType
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured.{`given`, requestSpecification}
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, ACCOUNT_ID, ANDRE, ANDRE_PASSWORD, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder}
+import org.apache.james.jmap.rfc8621.contract.tags.CategoryTags
+import org.apache.james.mailbox.model.MailboxPath
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.modules.protocols.SmtpGuiceProbe
+import org.apache.james.utils.{DataProbeImpl, SMTPMessageSender, SpoolerProbe, UpdatableTickingClock}
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.awaitility.core.ConditionFactory
+import org.junit.jupiter.api.{BeforeEach, Tag, Test}
+import org.mockserver.integration.ClientAndServer
+import org.mockserver.mock.action.ExpectationResponseCallback
+import org.mockserver.model.HttpRequest.request
+import org.mockserver.model.HttpResponse.response
+import org.mockserver.model.JsonBody.json
+import org.mockserver.model.Not.not
+import org.mockserver.model.{HttpRequest, HttpResponse}
+import org.mockserver.verify.VerificationTimes
+import play.api.libs.json.{JsObject, JsString, Json}
+
+import java.nio.charset.StandardCharsets
+import java.security.KeyPair
+import java.security.interfaces.{ECPrivateKey, ECPublicKey}
+import java.time.temporal.ChronoUnit
+import java.util.Base64
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
+trait WebPushContract {
+  private lazy val awaitAtMostTenSeconds: ConditionFactory = Awaitility.`with`
+    .pollInterval(ONE_HUNDRED_MILLISECONDS)
+    .and.`with`.pollDelay(ONE_HUNDRED_MILLISECONDS)
+    .await
+    .atMost(10, TimeUnit.SECONDS)
+  private lazy val PUSH_URL_PATH: String = "/push2"
+
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent()
+      .addDomain(DOMAIN.asString())
+      .addUser(BOB.asString(), BOB_PASSWORD)
+      .addUser(ANDRE.asString(), ANDRE_PASSWORD)
+
+    server.getProbe(classOf[MailboxProbeImpl])
+      .createMailbox(MailboxPath.inbox(BOB))
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .build()
+  }
+
+  private def getPushServerUrl(pushServer: ClientAndServer): String =
+    s"http://127.0.0.1:${pushServer.getLocalPort}$PUSH_URL_PATH"
+
+  // return pushSubscriptionId
+  private def createPushSubscription(pushServer: ClientAndServer): String =
+    `given`
+      .body(
+        s"""{
+           |    "using": ["urn:ietf:params:jmap:core"],
+           |    "methodCalls": [
+           |      [
+           |        "PushSubscription/set",
+           |        {
+           |            "create": {
+           |                "4f29": {
+           |                  "deviceClientId": "a889-ffea-910",
+           |                  "url": "${getPushServerUrl(pushServer)}",
+           |                  "types": ["Mailbox"]
+           |                }
+           |              }
+           |        },
+           |        "c1"
+           |      ]
+           |    ]
+           |  }""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .extract()
+      .jsonPath()
+      .get("methodResponses[0][1].created.4f29.id")
+
+  private def updateValidateVerificationCode(pushSubscriptionId: String, verificationCode: String): String =
+    `given`()
+      .body(
+        s"""{
+           |    "using": ["urn:ietf:params:jmap:core"],
+           |    "methodCalls": [
+           |      [
+           |        "PushSubscription/set",
+           |        {
+           |            "update": {
+           |                "$pushSubscriptionId": {
+           |                  "verificationCode": "$verificationCode"
+           |                }
+           |              }
+           |        },
+           |        "c1"
+           |      ]
+           |    ]
+           |  }""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract()
+      .body()
+      .asString()
+
+  private def sendEmailToBob(server: GuiceJamesServer): Unit = {
+    val smtpMessageSender: SMTPMessageSender = new SMTPMessageSender(DOMAIN.asString())
+    smtpMessageSender.connect("127.0.0.1", server.getProbe(classOf[SmtpGuiceProbe]).getSmtpPort)
+      .authenticate(ANDRE.asString, ANDRE_PASSWORD)
+      .sendMessage(ANDRE.asString, BOB.asString())
+    smtpMessageSender.close()
+
+    awaitAtMostTenSeconds.until(() => server.getProbe(classOf[SpoolerProbe]).processingFinished())
+  }
+
+  private def setupPushServerCallback(pushServer: ClientAndServer): AtomicReference[String] = {
+    val bodyRequestOnPushServer: AtomicReference[String] = new AtomicReference("")
+    pushServer
+      .when(request
+        .withPath(PUSH_URL_PATH)
+        .withMethod("POST"))
+      .respond(new ExpectationResponseCallback() {
+        override def handle(httpRequest: HttpRequest): HttpResponse = {
+          bodyRequestOnPushServer.set(httpRequest.getBodyAsString)
+          response()
+            .withStatusCode(HttpStatus.SC_CREATED)
+        }
+      })
+    bodyRequestOnPushServer
+  }
+
+  @Test
+  @Tag(CategoryTags.BASIC_FEATURE)
+  def correctBehaviourShouldSuccess(server: GuiceJamesServer, pushServer: ClientAndServer): Unit = {
+    // Setup mock-server for callback
+    val bodyRequestOnPushServer: AtomicReference[String] = setupPushServerCallback(pushServer)
+
+    // WHEN bob creates a push subscription
+    val pushSubscriptionId: String = createPushSubscription(pushServer)
+    // THEN a validation code is sent
+    awaitAtMostTenSeconds.untilAsserted { () =>
+      pushServer.verify(HttpRequest.request()
+        .withPath(PUSH_URL_PATH)
+        .withBody(json(
+          s"""{
+             |    "@type": "PushVerification",
+             |    "pushSubscriptionId": "$pushSubscriptionId",
+             |    "verificationCode": "$${json-unit.any-string}"
+             |}""".stripMargin)),
+        VerificationTimes.atLeast(1))
+    }
+
+    // GIVEN bob retrieves the validation code from the mock server
+    val verificationCode: String = Json.parse(bodyRequestOnPushServer.get()).asInstanceOf[JsObject]
+      .value("verificationCode")
+      .asInstanceOf[JsString]
+      .value
+
+    // WHEN bob updates the validation code via JMAP
+    val updateVerificationCodeResponse: String = updateValidateVerificationCode(pushSubscriptionId, verificationCode)
+
+    // THEN  it succeed
+    assertThatJson(updateVerificationCodeResponse)
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "PushSubscription/set",
+           |            {
+           |                "updated": {
+           |                    "$pushSubscriptionId": {}
+           |                }
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+
+    // WHEN bob receives a mail
+    sendEmailToBob(server)
+
+    // THEN bob has a stateChange on the push gateway
+    awaitAtMostTenSeconds.untilAsserted { () =>
+      pushServer.verify(HttpRequest.request()
+        .withPath(PUSH_URL_PATH)
+        .withBody(json(
+          s"""{
+             |    "@type": "StateChange",
+             |    "changed": {
+             |        "$ACCOUNT_ID": {
+             |          "Mailbox": "$${json-unit.any-string}"
+             |        }
+             |    }
+             |}""".stripMargin)),
+        VerificationTimes.atLeast(1))
+    }
+  }
+
+  @Test
+  def webPushShouldNotPushToPushServerWhenExpiredSubscription(server: GuiceJamesServer, pushServer: ClientAndServer, clock: UpdatableTickingClock): Unit = {
+    // Setup mock-server for callback
+    val bodyRequestOnPushServer: AtomicReference[String] = setupPushServerCallback(pushServer)
+
+    // WHEN bob creates a push subscription
+    val pushSubscriptionId: String = createPushSubscription(pushServer)
+    // THEN a validation code is sent
+    awaitAtMostTenSeconds.untilAsserted { () =>
+      pushServer.verify(HttpRequest.request()
+        .withPath(PUSH_URL_PATH)
+        .withBody(json(
+          s"""{
+             |    "@type": "PushVerification",
+             |    "pushSubscriptionId": "$pushSubscriptionId",
+             |    "verificationCode": "$${json-unit.any-string}"
+             |}""".stripMargin)),
+        VerificationTimes.atLeast(1))
+    }
+
+    // GIVEN bob retrieves the validation code from the mock server
+    val verificationCode: String = Json.parse(bodyRequestOnPushServer.get()).asInstanceOf[JsObject]
+      .value("verificationCode")
+      .asInstanceOf[JsString]
+      .value
+
+    // WHEN bob updates the validation code via JMAP
+    val updateVerificationCodeResponse: String = updateValidateVerificationCode(pushSubscriptionId, verificationCode)
+
+    // THEN  it succeed
+    assertThatJson(updateVerificationCodeResponse)
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "PushSubscription/set",
+           |            {
+           |                "updated": {
+           |                    "$pushSubscriptionId": {}
+           |                }
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+
+    // GIVEN 8 days passes
+    clock.setInstant(clock.instant().plus(8, ChronoUnit.DAYS))
+
+    // WHEN bob receives a mail
+    sendEmailToBob(server)
+
+    // THEN bob has a stateChange on the push gateway
+    TimeUnit.MILLISECONDS.sleep(200)
+
+    pushServer.verify(HttpRequest.request()
+      .withPath(PUSH_URL_PATH)
+      .withBody(json(
+        s"""{
+           |    "@type": "StateChange",
+           |    "changed": {
+           |        "$ACCOUNT_ID": {
+           |          "Mailbox": "$${json-unit.any-string}"
+           |        }
+           |    }
+           |}""".stripMargin)),
+      VerificationTimes.exactly(0))
+  }
+
+  @Test
+  def webPushShouldNotPushToPushServerWhenDeletedSubscription(server: GuiceJamesServer, pushServer: ClientAndServer): Unit = {
+    // Setup mock-server for callback
+    val bodyRequestOnPushServer: AtomicReference[String] = setupPushServerCallback(pushServer)
+
+    // WHEN bob creates a push subscription
+    val pushSubscriptionId: String = createPushSubscription(pushServer)
+    // THEN a validation code is sent
+    awaitAtMostTenSeconds.untilAsserted { () =>
+      pushServer.verify(HttpRequest.request()
+        .withPath(PUSH_URL_PATH)
+        .withBody(json(
+          s"""{
+             |    "@type": "PushVerification",
+             |    "pushSubscriptionId": "$pushSubscriptionId",
+             |    "verificationCode": "$${json-unit.any-string}"
+             |}""".stripMargin)),
+        VerificationTimes.atLeast(1))
+    }
+
+    // GIVEN bob retrieves the validation code from the mock server
+    val verificationCode: String = Json.parse(bodyRequestOnPushServer.get()).asInstanceOf[JsObject]
+      .value("verificationCode")
+      .asInstanceOf[JsString]
+      .value
+
+    // WHEN bob updates the validation code via JMAP
+    val updateVerificationCodeResponse: String = updateValidateVerificationCode(pushSubscriptionId, verificationCode)
+
+    // THEN  it succeed
+    assertThatJson(updateVerificationCodeResponse)
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "PushSubscription/set",
+           |            {
+           |                "updated": {
+           |                    "$pushSubscriptionId": {}
+           |                }
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+
+    // GIVEN bob deletes the push subscription
+    val pushSubscriptionProbe: PushSubscriptionProbe = server.getProbe(classOf[PushSubscriptionProbe])
+
+    `given`
+      .body(
+        s"""{
+           |    "using": ["urn:ietf:params:jmap:core"],
+           |    "methodCalls": [
+           |      [
+           |        "PushSubscription/set",
+           |        {
+           |            "destroy": ["$pushSubscriptionId"]
+           |        },
+           |        "c1"
+           |      ]
+           |    ]
+           |  }""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+
+    // WHEN bob receives a mail
+    sendEmailToBob(server)
+
+    // THEN bob has no stateChange on the push gateway
+    TimeUnit.MILLISECONDS.sleep(200)
+
+    pushServer.verify(HttpRequest.request()
+      .withPath(PUSH_URL_PATH)
+      .withBody(json(
+        s"""{
+           |    "@type": "StateChange",
+           |    "changed": {
+           |        "$ACCOUNT_ID": {
+           |          "Mailbox": "$${json-unit.any-string}"
+           |        }
+           |    }
+           |}""".stripMargin)),
+      VerificationTimes.exactly(0))
+  }
+
+  @Test
+  def webPushShouldNotPushToPushServerWhenNotValidatedCode(server: GuiceJamesServer, pushServer: ClientAndServer): Unit = {
+    // Setup mock-server for callback
+    setupPushServerCallback(pushServer)
+
+    // WHEN bob creates a push subscription [no code validation]
+    createPushSubscription(pushServer)
+
+    // GIVEN bob receives a mail
+    sendEmailToBob(server)
+
+    // THEN bob has no stateChange on the push gateway
+    TimeUnit.MILLISECONDS.sleep(200)
+
+    pushServer.verify(HttpRequest.request()
+      .withPath(PUSH_URL_PATH)
+      .withBody(json(
+        s"""{
+           |    "@type": "StateChange",
+           |    "changed": {
+           |        "$ACCOUNT_ID": {
+           |          "Mailbox": "$${json-unit.any-string}"
+           |        }
+           |    }
+           |}""".stripMargin)),
+      VerificationTimes.exactly(0))
+  }
+
+  @Test
+  def correctBehaviourShouldSuccessWhenEncryptionKeys(server: GuiceJamesServer, pushServer: ClientAndServer): Unit = {
+    // Setup mock-server for callback
+    val bodyRequestOnPushServer: AtomicReference[Array[Byte]] = new AtomicReference()
+
+    pushServer
+      .when(request
+        .withPath(PUSH_URL_PATH)
+        .withMethod("POST"))
+      .respond(new ExpectationResponseCallback() {
+        override def handle(httpRequest: HttpRequest): HttpResponse = {
+          bodyRequestOnPushServer.set(httpRequest.getBodyAsRawBytes)
+          response()
+            .withStatusCode(HttpStatus.SC_CREATED)
+        }
+      })
+
+    val uaKeyPair: KeyPair = EllipticCurves.generateKeyPair(CurveType.NIST_P256)
+    val uaPublicKey: ECPublicKey = uaKeyPair.getPublic.asInstanceOf[ECPublicKey]
+    val uaPrivateKey: ECPrivateKey = uaKeyPair.getPrivate.asInstanceOf[ECPrivateKey]
+    val authSecret: Array[Byte] = "secret123secret1".getBytes
+
+    val p256dh: String = Base64.getUrlEncoder.encodeToString(uaPublicKey.getEncoded)
+    val auth: String = Base64.getUrlEncoder.encodeToString(authSecret)
+
+    val pushSubscriptionId: String = `given`
+      .body(
+        s"""{
+           |    "using": ["urn:ietf:params:jmap:core"],
+           |    "methodCalls": [
+           |      [
+           |        "PushSubscription/set",
+           |        {
+           |            "create": {
+           |                "4f29": {
+           |                  "deviceClientId": "a889-ffea-910",
+           |                  "url": "${getPushServerUrl(pushServer)}",
+           |                  "types": ["Mailbox"],
+           |                  "keys": {
+           |                    "p256dh": "$p256dh",
+           |                    "auth": "$auth"
+           |                  }
+           |                }
+           |              }
+           |        },
+           |        "c1"
+           |      ]
+           |    ]
+           |  }""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .extract()
+      .jsonPath()
+      .get("methodResponses[0][1].created.4f29.id")
+
+    // THEN a validation code is sent
+    awaitAtMostTenSeconds.untilAsserted { () =>
+      pushServer.verify(HttpRequest.request()
+        .withPath(PUSH_URL_PATH)
+        .withBody(not(json(
+          s"""{
+             |    "@type": "PushVerification",
+             |    "pushSubscriptionId": "$pushSubscriptionId",
+             |    "verificationCode": "$${json-unit.any-string}"
+             |}""".stripMargin))),
+        VerificationTimes.atLeast(1))
+    }
+
+    val hybridDecrypt: WebPushHybridDecrypt = new WebPushHybridDecrypt.Builder()
+      .withAuthSecret(authSecret)
+      .withRecipientPublicKey(uaPublicKey)
+      .withRecipientPrivateKey(uaPrivateKey)
+      .build
+
+    val decryptBodyRequestOnPushServer: String = new String(hybridDecrypt.decrypt(bodyRequestOnPushServer.get(), null), StandardCharsets.UTF_8)
+
+    // GIVEN bob retrieves the validation code from the mock server
+    val verificationCode: String = Json.parse(decryptBodyRequestOnPushServer).asInstanceOf[JsObject]
+      .value("verificationCode")
+      .asInstanceOf[JsString]
+      .value
+
+    // WHEN bob updates the validation code via JMAP
+    updateValidateVerificationCode(pushSubscriptionId, verificationCode)
+
+    // WHEN bob receives a mail
+    sendEmailToBob(server)
+
+    // THEN bob has a stateChange on the push gateway
+    awaitAtMostTenSeconds.untilAsserted { () =>
+      pushServer.verify(HttpRequest.request()
+        .withPath(PUSH_URL_PATH),
+        VerificationTimes.atLeast(1))
+    }
+
+    assertThatJson(new String(hybridDecrypt.decrypt(bodyRequestOnPushServer.get(), null), StandardCharsets.UTF_8))
+      .isEqualTo(
+        s"""{
+           |    "@type": "StateChange",
+           |    "changed": {
+           |        "$ACCOUNT_ID": {
+           |          "Mailbox": "$${json-unit.ignore}"
+           |        }
+           |    }
+           |}""".stripMargin)
+  }
+}
diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/modules/protocols/JmapEventBusModule.java b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryWebPushTest.java
similarity index 52%
copy from server/container/guice/protocols/jmap/src/main/java/org/apache/james/modules/protocols/JmapEventBusModule.java
copy to server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryWebPushTest.java
index 0fafb26..d0d6b5c 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/modules/protocols/JmapEventBusModule.java
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryWebPushTest.java
@@ -17,17 +17,27 @@
  * under the License.                                           *
  ****************************************************************/
 
-package org.apache.james.modules.protocols;
+package org.apache.james.jmap.rfc8621.memory;
 
-import org.apache.james.events.EventBus;
-import org.apache.james.jmap.InjectionKeys;
+import org.apache.james.ClockExtension;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.MemoryJamesServerMain;
+import org.apache.james.jmap.rfc8621.contract.PushServerExtension;
+import org.apache.james.jmap.rfc8621.contract.PushSubscriptionProbeModule;
+import org.apache.james.jmap.rfc8621.contract.WebPushContract;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
 
-import com.google.inject.AbstractModule;
-import com.google.inject.name.Names;
+public class MemoryWebPushTest implements WebPushContract {
 
-public class JmapEventBusModule extends AbstractModule {
-    @Override
-    protected void configure() {
-        bind(EventBus.class).annotatedWith(Names.named(InjectionKeys.JMAP)).to(EventBus.class);
-    }
+    @RegisterExtension
+    static JamesServerExtension testExtension = new JamesServerBuilder<>(JamesServerBuilder.defaultConfigurationProvider())
+        .server(configuration -> MemoryJamesServerMain.createServer(configuration)
+            .overrideWith(new TestJMAPServerModule(), new PushSubscriptionProbeModule()))
+        .extension(new ClockExtension())
+        .build();
+
+    @RegisterExtension
+    static PushServerExtension pushServerExtension = new PushServerExtension();
 }

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org