com.turo.pushy.apns.ApnsClientTest.java Source code

Java tutorial

Introduction

Here is the source code for com.turo.pushy.apns.ApnsClientTest.java

Source

/*
 * Copyright (c) 2013-2017 Turo
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

package com.turo.pushy.apns;

import com.turo.pushy.apns.server.*;
import com.turo.pushy.apns.util.SimpleApnsPushNotification;
import com.turo.pushy.apns.util.concurrent.PushNotificationFuture;
import com.turo.pushy.apns.util.concurrent.PushNotificationResponseListener;
import io.netty.buffer.ByteBuf;
import io.netty.handler.codec.http2.Http2Headers;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GenericFutureListener;
import junitparams.JUnitParamsRunner;
import junitparams.Parameters;
import org.junit.Test;
import org.junit.runner.RunWith;

import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLSession;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import static org.junit.Assert.*;

@RunWith(JUnitParamsRunner.class)
public class ApnsClientTest extends AbstractClientServerTest {

    private static class TestMetricsListener implements ApnsClientMetricsListener {

        private final List<Long> writeFailures = new ArrayList<>();
        private final List<Long> sentNotifications = new ArrayList<>();
        private final List<Long> acceptedNotifications = new ArrayList<>();
        private final List<Long> rejectedNotifications = new ArrayList<>();

        private final AtomicInteger connectionsAdded = new AtomicInteger(0);
        private final AtomicInteger connectionsRemoved = new AtomicInteger(0);
        private final AtomicInteger failedConnectionAttempts = new AtomicInteger(0);

        @Override
        public void handleWriteFailure(final ApnsClient apnsClient, final long notificationId) {
            synchronized (this.writeFailures) {
                this.writeFailures.add(notificationId);
                this.writeFailures.notifyAll();
            }
        }

        @Override
        public void handleNotificationSent(final ApnsClient apnsClient, final long notificationId) {
            this.sentNotifications.add(notificationId);
        }

        @Override
        public void handleNotificationAccepted(final ApnsClient apnsClient, final long notificationId) {
            synchronized (this.acceptedNotifications) {
                this.acceptedNotifications.add(notificationId);
                this.acceptedNotifications.notifyAll();
            }
        }

        @Override
        public void handleNotificationRejected(final ApnsClient apnsClient, final long notificationId) {
            synchronized (this.rejectedNotifications) {
                this.rejectedNotifications.add(notificationId);
                this.rejectedNotifications.notifyAll();
            }
        }

        @Override
        public void handleConnectionAdded(final ApnsClient apnsClient) {
            synchronized (this.connectionsAdded) {
                this.connectionsAdded.incrementAndGet();
                this.connectionsAdded.notifyAll();
            }
        }

        @Override
        public void handleConnectionRemoved(final ApnsClient apnsClient) {
            synchronized (this.connectionsRemoved) {
                this.connectionsRemoved.incrementAndGet();
                this.connectionsRemoved.notifyAll();
            }
        }

        @Override
        public void handleConnectionCreationFailed(final ApnsClient apnsClient) {
            synchronized (this.failedConnectionAttempts) {
                this.failedConnectionAttempts.incrementAndGet();
                this.failedConnectionAttempts.notifyAll();
            }
        }

        void waitForNonZeroWriteFailures() throws InterruptedException {
            synchronized (this.writeFailures) {
                while (this.writeFailures.isEmpty()) {
                    this.writeFailures.wait();
                }
            }
        }

        void waitForNonZeroAcceptedNotifications() throws InterruptedException {
            synchronized (this.acceptedNotifications) {
                while (this.acceptedNotifications.isEmpty()) {
                    this.acceptedNotifications.wait();
                }
            }
        }

        void waitForNonZeroRejectedNotifications() throws InterruptedException {
            synchronized (this.rejectedNotifications) {
                while (this.rejectedNotifications.isEmpty()) {
                    this.rejectedNotifications.wait();
                }
            }
        }

        void waitForNonZeroFailedConnections() throws InterruptedException {
            synchronized (this.failedConnectionAttempts) {
                while (this.failedConnectionAttempts.get() == 0) {
                    this.failedConnectionAttempts.wait();
                }
            }
        }

        List<Long> getWriteFailures() {
            return this.writeFailures;
        }

        List<Long> getSentNotifications() {
            return this.sentNotifications;
        }

        List<Long> getAcceptedNotifications() {
            return this.acceptedNotifications;
        }

        List<Long> getRejectedNotifications() {
            return this.rejectedNotifications;
        }

        AtomicInteger getConnectionsAdded() {
            return this.connectionsAdded;
        }

        AtomicInteger getConnectionsRemoved() {
            return this.connectionsRemoved;
        }

        AtomicInteger getFailedConnectionAttempts() {
            return this.failedConnectionAttempts;
        }
    }

    @Test
    public void testApnsClientWithManagedEventLoopGroup() throws Exception {
        final ApnsClient managedGroupClient = new ApnsClientBuilder().setApnsServer(HOST, PORT)
                .setSigningKey(this.signingKey).build();

        assertTrue(managedGroupClient.close().await().isSuccess());
    }

    @Test
    @Parameters({ "true", "false" })
    public void testSendNotificationToUntrustedServer(final boolean useTokenAuthentication) throws Exception {
        final ApnsClient cautiousClient;

        if (useTokenAuthentication) {
            cautiousClient = new ApnsClientBuilder().setApnsServer(HOST, PORT).setSigningKey(this.signingKey)
                    .setEventLoopGroup(CLIENT_EVENT_LOOP_GROUP).build();
        } else {
            try (final InputStream p12InputStream = getClass()
                    .getResourceAsStream(MULTI_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
                cautiousClient = new ApnsClientBuilder().setApnsServer(HOST, PORT)
                        .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
                        .setEventLoopGroup(CLIENT_EVENT_LOOP_GROUP).build();
            }
        }

        final MockApnsServer server = this.buildServer(new AcceptAllPushNotificationHandlerFactory());

        try {
            server.start(PORT).await();

            final Future<PushNotificationResponse<SimpleApnsPushNotification>> sendFuture = cautiousClient
                    .sendNotification(new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC, PAYLOAD)).await();

            assertFalse("Clients must not connect to untrusted servers.", sendFuture.isSuccess());

            assertTrue("Clients should refuse to connect to untrusted servers due to an SSL handshake failure.",
                    sendFuture.cause() instanceof SSLHandshakeException);
        } finally {
            cautiousClient.close().await();
            server.shutdown().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testRepeatedClose(final boolean useTokenAuthentication) throws Exception {
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient()
                : this.buildTlsAuthenticationClient();

        try {
            assertTrue("Client should close successfully under normal circumstances.",
                    client.close().await().isSuccess());

            assertTrue("Client should report successful closure on subsequent calls to close().",
                    client.close().await().isSuccess());
        } finally {
            client.close().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testSendNotificationAfterClose(final boolean useTokenAuthentication) throws Exception {
        final MockApnsServer server = this.buildServer(new AcceptAllPushNotificationHandlerFactory());
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient()
                : this.buildTlsAuthenticationClient();

        try {
            server.start(PORT).await();
            client.close().await();

            final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC,
                    PAYLOAD);

            final Future<PushNotificationResponse<SimpleApnsPushNotification>> sendFuture = client
                    .sendNotification(pushNotification).await();

            assertFalse("Once a client has closed, attempts to send push notifications should fail.",
                    sendFuture.isSuccess());
        } finally {
            client.close().await();
            server.shutdown().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testSendNotification(final boolean useTokenAuthentication) throws Exception {
        final ValidatingPushNotificationHandlerFactory handlerFactory = new ValidatingPushNotificationHandlerFactory(
                DEVICE_TOKENS_BY_TOPIC, EXPIRATION_TIMESTAMPS_BY_DEVICE_TOKEN, this.verificationKeysByKeyId,
                this.topicsByVerificationKey);

        final MockApnsServer server = this.buildServer(handlerFactory);
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient()
                : this.buildTlsAuthenticationClient();

        try {
            server.start(PORT).await();

            final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC,
                    PAYLOAD);

            final PushNotificationResponse<SimpleApnsPushNotification> response = client
                    .sendNotification(pushNotification).get();

            assertTrue("Clients must send notifications that conform to the APNs protocol specification.",
                    response.isAccepted());

            assertNotNull(response.getApnsId());
        } finally {
            client.close().await();
            server.shutdown().await();
        }
    }

    @Test
    public void testSendNotificationWithExpiredAuthenticationToken() throws Exception {
        final PushNotificationHandlerFactory expireFirstTokenHandlerFactory = new PushNotificationHandlerFactory() {
            @Override
            public PushNotificationHandler buildHandler(final SSLSession sslSession) {
                return new ExpireFirstTokenPushNotificationHandler();
            }
        };

        final MockApnsServer server = this.buildServer(expireFirstTokenHandlerFactory);

        final TestMetricsListener metricsListener = new TestMetricsListener();
        final ApnsClient client = this.buildTokenAuthenticationClient(metricsListener);

        try {
            server.start(PORT).await();

            final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC,
                    PAYLOAD);

            final PushNotificationResponse<SimpleApnsPushNotification> response = client
                    .sendNotification(pushNotification).get();

            assertTrue("Client should automatically re-send notifications with expired authentication tokens.",
                    response.isAccepted());

            metricsListener.waitForNonZeroAcceptedNotifications();

            // See https://github.com/relayrides/pushy/issues/448
            assertEquals("Re-sent notifications with expired tokens must not be double-counted.", 1,
                    metricsListener.getSentNotifications().size());

            assertEquals("Re-sent notifications should be counted as accepted exactly once.", 1,
                    metricsListener.getAcceptedNotifications().size());

            assertTrue("Notifications with expired authentication tokens should not count as rejections.",
                    metricsListener.getRejectedNotifications().isEmpty());
        } finally {
            client.close().await();
            server.shutdown().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testSendManyNotifications(final boolean useTokenAuthentication) throws Exception {
        final int notificationCount = 1000;

        final List<SimpleApnsPushNotification> pushNotifications = new ArrayList<>();

        for (int i = 0; i < notificationCount; i++) {
            final String token = ApnsClientTest.generateRandomDeviceToken();
            final String payload = ApnsClientTest.generateRandomPayload();

            pushNotifications.add(new SimpleApnsPushNotification(token, TOPIC, payload));
        }

        final List<Future<PushNotificationResponse<SimpleApnsPushNotification>>> futures = new ArrayList<>();

        final MockApnsServer server = this.buildServer(new AcceptAllPushNotificationHandlerFactory());
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient()
                : this.buildTlsAuthenticationClient();

        try {
            server.start(PORT).await();

            for (final SimpleApnsPushNotification pushNotification : pushNotifications) {
                futures.add(client.sendNotification(pushNotification));
            }

            for (final Future<PushNotificationResponse<SimpleApnsPushNotification>> future : futures) {
                future.await();

                assertTrue("Send future should have succeeded, but failed with: " + future.cause(),
                        future.isSuccess());
            }
        } finally {
            client.close().await();
            server.shutdown().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testSendManyNotificationsWithListeners(final boolean useTokenAuthentication) throws Exception {
        final int notificationCount = 1000;

        final List<SimpleApnsPushNotification> pushNotifications = new ArrayList<>();

        for (int i = 0; i < notificationCount; i++) {
            final String token = ApnsClientTest.generateRandomDeviceToken();
            final String payload = ApnsClientTest.generateRandomPayload();

            pushNotifications.add(new SimpleApnsPushNotification(token, TOPIC, payload));
        }

        final MockApnsServer server = this.buildServer(new AcceptAllPushNotificationHandlerFactory());
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient()
                : this.buildTlsAuthenticationClient();

        final CountDownLatch countDownLatch = new CountDownLatch(notificationCount);

        try {
            server.start(PORT).await();

            for (final SimpleApnsPushNotification pushNotification : pushNotifications) {
                final Future<PushNotificationResponse<SimpleApnsPushNotification>> future = client
                        .sendNotification(pushNotification);

                future.addListener(
                        new GenericFutureListener<Future<PushNotificationResponse<SimpleApnsPushNotification>>>() {

                            @Override
                            public void operationComplete(
                                    final Future<PushNotificationResponse<SimpleApnsPushNotification>> future) {
                                if (future.isSuccess()) {
                                    countDownLatch.countDown();
                                }
                            }
                        });
            }

            countDownLatch.await();
        } finally {
            client.close().await();
            server.shutdown().await();
        }
    }

    // See https://github.com/relayrides/pushy/issues/256
    @Test
    @Parameters({ "true", "false" })
    public void testRepeatedlySendSameNotification(final boolean useTokenAuthentication) throws Exception {
        final int notificationCount = 1000;

        final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC,
                PAYLOAD);

        final CountDownLatch countDownLatch = new CountDownLatch(notificationCount);

        final MockApnsServer server = this.buildServer(new AcceptAllPushNotificationHandlerFactory());
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient()
                : this.buildTlsAuthenticationClient();

        try {
            server.start(PORT).await();

            for (int i = 0; i < notificationCount; i++) {
                final Future<PushNotificationResponse<SimpleApnsPushNotification>> future = client
                        .sendNotification(pushNotification);

                future.addListener(
                        new GenericFutureListener<Future<PushNotificationResponse<SimpleApnsPushNotification>>>() {

                            @Override
                            public void operationComplete(
                                    final Future<PushNotificationResponse<SimpleApnsPushNotification>> future) {
                                // All we're concerned with here is that the client told us SOMETHING about what happened to the
                                // notification
                                countDownLatch.countDown();
                            }
                        });
            }

            countDownLatch.await();
        } finally {
            client.close().await();
            server.shutdown().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testSendNotificationWithExpiredDeviceToken(final boolean useTokenAuthentication) throws Exception {
        final Date expiration = new Date();

        final PushNotificationHandlerFactory handlerFactory = new PushNotificationHandlerFactory() {
            @Override
            public PushNotificationHandler buildHandler(final SSLSession sslSession) {
                return new PushNotificationHandler() {
                    @Override
                    public void handlePushNotification(final Http2Headers headers, final ByteBuf payload)
                            throws RejectedNotificationException {
                        throw new UnregisteredDeviceTokenException(expiration);
                    }
                };
            }
        };

        final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC,
                PAYLOAD);

        final MockApnsServer server = this.buildServer(handlerFactory);
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient()
                : this.buildTlsAuthenticationClient();

        try {
            server.start(PORT).await();

            final PushNotificationResponse<SimpleApnsPushNotification> response = client
                    .sendNotification(pushNotification).get();

            assertFalse(response.isAccepted());
            assertEquals("Unregistered", response.getRejectionReason());
            assertEquals(expiration, response.getTokenInvalidationTimestamp());
        } finally {
            client.close().await();
            server.shutdown().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testAcceptedNotificationAndAddedConnectionMetrics(final boolean useTokenAuthentication)
            throws Exception {
        final MockApnsServer server = this.buildServer(new AcceptAllPushNotificationHandlerFactory());

        final TestMetricsListener metricsListener = new TestMetricsListener();
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient(metricsListener)
                : this.buildTlsAuthenticationClient(metricsListener);

        try {
            server.start(PORT).await();

            final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC,
                    PAYLOAD);

            client.sendNotification(pushNotification).await();

            metricsListener.waitForNonZeroAcceptedNotifications();

            assertEquals(1, metricsListener.getSentNotifications().size());
            assertEquals(metricsListener.getSentNotifications(), metricsListener.getAcceptedNotifications());
            assertTrue(metricsListener.getRejectedNotifications().isEmpty());

            assertEquals(1, metricsListener.getConnectionsAdded().get());
            assertEquals(0, metricsListener.getConnectionsRemoved().get());
            assertEquals(0, metricsListener.getFailedConnectionAttempts().get());
        } finally {
            client.close().await();
            server.shutdown().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testRejectedNotificationMetrics(final boolean useTokenAuthentication) throws Exception {
        final PushNotificationHandlerFactory handlerFactory = new PushNotificationHandlerFactory() {
            @Override
            public PushNotificationHandler buildHandler(final SSLSession sslSession) {
                return new PushNotificationHandler() {
                    @Override
                    public void handlePushNotification(final Http2Headers headers, final ByteBuf payload)
                            throws RejectedNotificationException {
                        throw new RejectedNotificationException(RejectionReason.BAD_DEVICE_TOKEN);
                    }
                };
            }
        };

        final MockApnsServer server = this.buildServer(handlerFactory);

        final TestMetricsListener metricsListener = new TestMetricsListener();
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient(metricsListener)
                : this.buildTlsAuthenticationClient(metricsListener);

        try {
            server.start(PORT).await();

            final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC,
                    PAYLOAD);

            client.sendNotification(pushNotification).await();

            metricsListener.waitForNonZeroRejectedNotifications();

            assertEquals(1, metricsListener.getSentNotifications().size());
            assertEquals(metricsListener.getSentNotifications(), metricsListener.getRejectedNotifications());
            assertTrue(metricsListener.getAcceptedNotifications().isEmpty());
        } finally {
            client.close().await();
            server.shutdown().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testFailedConnectionAndWriteFailureMetrics(final boolean useTokenAuthentication) throws Exception {
        final TestMetricsListener metricsListener = new TestMetricsListener();

        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient(metricsListener)
                : this.buildTlsAuthenticationClient(metricsListener);

        try {
            final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC,
                    PAYLOAD);

            client.sendNotification(pushNotification).await();

            metricsListener.waitForNonZeroFailedConnections();
            metricsListener.waitForNonZeroWriteFailures();

            assertEquals(0, metricsListener.getConnectionsAdded().get());
            assertEquals(0, metricsListener.getConnectionsRemoved().get());
            assertEquals(1, metricsListener.getFailedConnectionAttempts().get());

            assertEquals(1, metricsListener.getWriteFailures().size());
        } finally {
            client.close().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testRepeatedlySendNotificationAfterConnectionFailure(final boolean useTokenAuthentication)
            throws Exception {
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient()
                : this.buildTlsAuthenticationClient();

        try {
            final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC,
                    PAYLOAD);

            for (int i = 0; i < 3; i++) {
                // We should see delays of roughly 0, 1, and 2 seconds; 4 seconds per notification is excessive, but
                // better to play it safe with a timed assertion.
                final Future<PushNotificationResponse<SimpleApnsPushNotification>> sendFuture = client
                        .sendNotification(pushNotification);

                assertTrue(sendFuture.await(4, TimeUnit.SECONDS));
                assertFalse(sendFuture.isSuccess());
            }
        } finally {
            client.close().await();
        }
    }

    @Test
    @Parameters({ "true", "false" })
    public void testRepeatedlySendNotificationAfterConnectionFailureWithListeners(
            final boolean useTokenAuthentication) throws Exception {
        final ApnsClient client = useTokenAuthentication ? this.buildTokenAuthenticationClient()
                : this.buildTlsAuthenticationClient();

        try {
            final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC,
                    PAYLOAD);

            final int notificationCount = 3;

            final CountDownLatch countDownLatch = new CountDownLatch(notificationCount);

            for (int i = 0; i < notificationCount; i++) {
                client.sendNotification(pushNotification)
                        .addListener(new PushNotificationResponseListener<SimpleApnsPushNotification>() {

                            @Override
                            public void operationComplete(
                                    final PushNotificationFuture<SimpleApnsPushNotification, PushNotificationResponse<SimpleApnsPushNotification>> simpleApnsPushNotificationPushNotificationResponsePushNotificationFuture)
                                    throws Exception {
                                countDownLatch.countDown();
                            }
                        });
            }

            // We should see delays of roughly 0, 1, and 2 seconds (for a total of 3 seconds); waiting 6 seconds in
            // total is overkill, but it's best to leave significant margin on timed assertions.
            assertTrue(countDownLatch.await(6, TimeUnit.SECONDS));
        } finally {
            client.close().await();
        }
    }
}