|
@@ -0,0 +1,189 @@
|
|
|
|
|
+package com.ruoyi.app.push;
|
|
|
|
|
+
|
|
|
|
|
+import com.alibaba.fastjson2.JSON;
|
|
|
|
|
+import com.alibaba.fastjson2.JSONObject;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.junit.jupiter.api.io.TempDir;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.ByteArrayOutputStream;
|
|
|
|
|
+import java.net.http.HttpClient;
|
|
|
|
|
+import java.net.http.HttpRequest;
|
|
|
|
|
+import java.net.http.HttpResponse;
|
|
|
|
|
+import java.nio.ByteBuffer;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.nio.file.Files;
|
|
|
|
|
+import java.nio.file.Path;
|
|
|
|
|
+import java.security.KeyStore;
|
|
|
|
|
+import java.security.cert.CertificateExpiredException;
|
|
|
|
|
+import java.security.cert.X509Certificate;
|
|
|
|
|
+import java.util.EnumMap;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.concurrent.CompletableFuture;
|
|
|
|
|
+import java.util.concurrent.Flow;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.Mockito.doThrow;
|
|
|
|
|
+import static org.mockito.Mockito.doReturn;
|
|
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+
|
|
|
|
|
+class ApnsPushSenderTest {
|
|
|
|
|
+ @TempDir
|
|
|
|
|
+ Path tempDir;
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void routesThreeAppsToOwnCertificateTopicAndSandbox() throws Exception {
|
|
|
|
|
+ StubSender sender = new StubSender(nativeProperties(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT));
|
|
|
|
|
+ sender.initialize();
|
|
|
|
|
+
|
|
|
|
|
+ for (RecipientApp app : RecipientApp.values()) {
|
|
|
|
|
+ assertEquals(app.name().toLowerCase() + ".p12", sender.loadedPaths.get(app));
|
|
|
|
|
+ sender.send(app, "a".repeat(64), "New order", "Order body",
|
|
|
|
|
+ "{\"ddId\":123,\"state\":2,\"type\":0,\"pushType\":\"order\"}");
|
|
|
|
|
+
|
|
|
|
|
+ org.mockito.ArgumentCaptor<HttpRequest> captured = org.mockito.ArgumentCaptor.forClass(HttpRequest.class);
|
|
|
|
|
+ verify(sender.clients.get(app)).send(captured.capture(), any());
|
|
|
|
|
+ HttpRequest request = captured.getValue();
|
|
|
|
|
+ assertEquals("api.sandbox.push.apple.com", request.uri().getHost());
|
|
|
|
|
+ assertEquals("/3/device/" + "a".repeat(64), request.uri().getPath());
|
|
|
|
|
+ assertEquals("com.example." + app.name().toLowerCase(), request.headers().firstValue("apns-topic").orElseThrow());
|
|
|
|
|
+ assertEquals("alert", request.headers().firstValue("apns-push-type").orElseThrow());
|
|
|
|
|
+ JSONObject body = JSON.parseObject(bodyOf(request));
|
|
|
|
|
+ JSONObject aps = body.getJSONObject("aps");
|
|
|
|
|
+ assertEquals("New order", aps.getJSONObject("alert").getString("title"));
|
|
|
|
|
+ assertEquals("Order body", aps.getJSONObject("alert").getString("body"));
|
|
|
|
|
+ assertEquals(app == RecipientApp.RIDER ? "pushsound.caf" : "default", aps.getString("sound"));
|
|
|
|
|
+ assertEquals("123", body.getString("ddId"));
|
|
|
|
|
+ assertEquals("order", body.getString("pushType"));
|
|
|
|
|
+ assertTrue(body.getString("payload").contains("\"ddId\":123"));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void productionModeUsesProductionGateway() throws Exception {
|
|
|
|
|
+ StubSender sender = new StubSender(nativeProperties(BusinessPushProperties.ApnsEnvironment.PRODUCTION));
|
|
|
|
|
+ sender.initialize();
|
|
|
|
|
+
|
|
|
|
|
+ sender.send(RecipientApp.USER, "b".repeat(64), "title", "body", "{}");
|
|
|
|
|
+
|
|
|
|
|
+ org.mockito.ArgumentCaptor<HttpRequest> captured = org.mockito.ArgumentCaptor.forClass(HttpRequest.class);
|
|
|
|
|
+ verify(sender.clients.get(RecipientApp.USER)).send(captured.capture(), any());
|
|
|
|
|
+ assertEquals("api.push.apple.com", captured.getValue().uri().getHost());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void rejectsMalformedAndKeylessCertificatesAtInitialization() throws Exception {
|
|
|
|
|
+ BusinessPushProperties properties = nativeProperties(BusinessPushProperties.ApnsEnvironment.PRODUCTION);
|
|
|
|
|
+ Path malformed = tempDir.resolve("malformed.p12");
|
|
|
|
|
+ Files.writeString(malformed, "not a PKCS12 file");
|
|
|
|
|
+ properties.getApp(RecipientApp.USER).setApnsCertificatePath(malformed.toString());
|
|
|
|
|
+ assertThrows(IllegalStateException.class, () -> new ApnsPushSender(properties).initialize());
|
|
|
|
|
+
|
|
|
|
|
+ Path keyless = tempDir.resolve("keyless.p12");
|
|
|
|
|
+ KeyStore store = KeyStore.getInstance("PKCS12");
|
|
|
|
|
+ store.load(null, null);
|
|
|
|
|
+ try (java.io.OutputStream stream = Files.newOutputStream(keyless)) {
|
|
|
|
|
+ store.store(stream, "test-password".toCharArray());
|
|
|
|
|
+ }
|
|
|
|
|
+ properties.getApp(RecipientApp.USER).setApnsCertificatePath(keyless.toString());
|
|
|
|
|
+ assertThrows(IllegalStateException.class, () -> new ApnsPushSender(properties).initialize());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void rejectsExpiredCertificate() throws Exception {
|
|
|
|
|
+ X509Certificate certificate = mock(X509Certificate.class);
|
|
|
|
|
+ doThrow(new CertificateExpiredException()).when(certificate).checkValidity();
|
|
|
|
|
+
|
|
|
|
|
+ assertThrows(IllegalStateException.class,
|
|
|
|
|
+ () -> ApnsPushSender.validateCertificate(certificate, RecipientApp.USER));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void classifiesApnsErrorWithoutExposingToken() throws Exception {
|
|
|
|
|
+ StubSender sender = new StubSender(nativeProperties(BusinessPushProperties.ApnsEnvironment.PRODUCTION));
|
|
|
|
|
+ sender.initialize();
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ HttpResponse<String> failure = mock(HttpResponse.class);
|
|
|
|
|
+ when(failure.statusCode()).thenReturn(400);
|
|
|
|
|
+ when(failure.body()).thenReturn("{\"reason\":\"BadDeviceToken\"}");
|
|
|
|
|
+ doReturn(failure).when(sender.clients.get(RecipientApp.USER)).send(any(), any());
|
|
|
|
|
+
|
|
|
|
|
+ IllegalStateException error = assertThrows(IllegalStateException.class,
|
|
|
|
|
+ () -> sender.send(RecipientApp.USER, "c".repeat(64), "title", "body", "{}"));
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(error.getMessage().contains("BadDeviceToken"));
|
|
|
|
|
+ assertFalse(error.getMessage().contains("c".repeat(64)));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private BusinessPushProperties nativeProperties(BusinessPushProperties.ApnsEnvironment environment) {
|
|
|
|
|
+ BusinessPushProperties properties = new BusinessPushProperties();
|
|
|
|
|
+ properties.setMode(BusinessPushProperties.Mode.NATIVE);
|
|
|
|
|
+ properties.setApnsEnvironment(environment);
|
|
|
|
|
+ for (RecipientApp app : RecipientApp.values()) {
|
|
|
|
|
+ BusinessPushProperties.AppProperties config = new BusinessPushProperties.AppProperties();
|
|
|
|
|
+ config.setApnsCertificatePath(app.name().toLowerCase() + ".p12");
|
|
|
|
|
+ config.setApnsCertificatePassword("test-password");
|
|
|
|
|
+ config.setApnsTopic("com.example." + app.name().toLowerCase());
|
|
|
|
|
+ properties.getApps().put(app, config);
|
|
|
|
|
+ }
|
|
|
|
|
+ return properties;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String bodyOf(HttpRequest request) throws Exception {
|
|
|
|
|
+ CompletableFuture<byte[]> completed = new CompletableFuture<>();
|
|
|
|
|
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
|
|
|
|
|
+ request.bodyPublisher().orElseThrow().subscribe(new Flow.Subscriber<>() {
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void onSubscribe(Flow.Subscription subscription) {
|
|
|
|
|
+ subscription.request(Long.MAX_VALUE);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void onNext(ByteBuffer item) {
|
|
|
|
|
+ byte[] bytes = new byte[item.remaining()];
|
|
|
|
|
+ item.get(bytes);
|
|
|
|
|
+ output.writeBytes(bytes);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void onError(Throwable throwable) {
|
|
|
|
|
+ completed.completeExceptionally(throwable);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void onComplete() {
|
|
|
|
|
+ completed.complete(output.toByteArray());
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ return new String(completed.get(1, TimeUnit.SECONDS), StandardCharsets.UTF_8);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static class StubSender extends ApnsPushSender {
|
|
|
|
|
+ private final Map<RecipientApp, HttpClient> clients = new EnumMap<>(RecipientApp.class);
|
|
|
|
|
+ private final Map<RecipientApp, String> loadedPaths = new EnumMap<>(RecipientApp.class);
|
|
|
|
|
+
|
|
|
|
|
+ StubSender(BusinessPushProperties properties) throws Exception {
|
|
|
|
|
+ super(properties);
|
|
|
|
|
+ for (RecipientApp app : RecipientApp.values()) {
|
|
|
|
|
+ HttpClient client = mock(HttpClient.class);
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ HttpResponse<String> response = mock(HttpResponse.class);
|
|
|
|
|
+ when(response.statusCode()).thenReturn(200);
|
|
|
|
|
+ doReturn(response).when(client).send(any(), any());
|
|
|
|
|
+ clients.put(app, client);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ protected HttpClient createClient(RecipientApp app, BusinessPushProperties.AppProperties config) {
|
|
|
|
|
+ loadedPaths.put(app, config.getApnsCertificatePath());
|
|
|
|
|
+ return clients.get(app);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|