// Copyright 2018 Google LLC // // Licensed 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 com.google.android.datatransport.cct; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.absent; import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; import static com.github.tomakehurst.wiremock.client.WireMock.notMatching; import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.verify; import static com.google.android.datatransport.cct.CctTransportBackend.getTzOffset; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import android.content.Context; import android.net.ConnectivityManager; import android.net.NetworkInfo; import com.github.tomakehurst.wiremock.junit.WireMockRule; import com.google.android.datatransport.Encoding; import com.google.android.datatransport.backend.cct.BuildConfig; import com.google.android.datatransport.cct.internal.NetworkConnectionInfo; import com.google.android.datatransport.runtime.EncodedPayload; import com.google.android.datatransport.runtime.EventInternal; import com.google.android.datatransport.runtime.backends.BackendRequest; import com.google.android.datatransport.runtime.backends.BackendResponse; import com.google.android.datatransport.runtime.time.TestClock; import com.google.protobuf.ByteString; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.nio.charset.Charset; import java.util.Arrays; import java.util.zip.GZIPOutputStream; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; @RunWith(RobolectricTestRunner.class) public class CctTransportBackendTest { private static final long INITIAL_WALL_TIME = 200L; private static final long INITIAL_UPTIME = 10L; private static final ByteString PAYLOAD = ByteString.copyFrom("TelemetryData".getBytes(Charset.defaultCharset())); private static final String PAYLOAD_BYTE64 = "VGVsZW1ldHJ5RGF0YQ=="; private static final String JSON_PAYLOAD = "{\"hello\": false}"; private static final String JSON_PAYLOAD_ESCAPED = "{\\\"hello\\\": false}"; private static final int CODE = 5; private static final String TEST_NAME = "hello"; private static final Encoding PROTOBUF_ENCODING = Encoding.of("proto"); private static final Encoding JSON_ENCODING = Encoding.of("json"); private static final String TEST_ENDPOINT = "http://localhost:8999/api"; private static final String API_KEY = "api_key"; private static final String CCT_TRANSPORT_NAME = "3"; private static final String LEGACY_TRANSPORT_NAME = "3"; private TestClock wallClock = new TestClock(INITIAL_WALL_TIME); private TestClock uptimeClock = new TestClock(INITIAL_UPTIME); private CctTransportBackend BACKEND = new CctTransportBackend(RuntimeEnvironment.application, wallClock, uptimeClock); @Rule public WireMockRule wireMockRule = new WireMockRule(8999); private BackendRequest getCCTBackendRequest() { return getCCTBackendRequest(CCT_TRANSPORT_NAME, new CCTDestination(TEST_ENDPOINT, null)); } private BackendRequest getCCTBackendRequest(String transportName, CCTDestination destination) { return BackendRequest.builder() .setEvents( Arrays.asList( BACKEND.decorate( EventInternal.builder() .setEventMillis(INITIAL_WALL_TIME) .setUptimeMillis(INITIAL_UPTIME) .setTransportName(transportName) .setEncodedPayload( new EncodedPayload(PROTOBUF_ENCODING, PAYLOAD.toByteArray())) .build()), BACKEND.decorate( EventInternal.builder() .setEventMillis(INITIAL_WALL_TIME) .setUptimeMillis(INITIAL_UPTIME) .setTransportName(transportName) .setEncodedPayload( new EncodedPayload( JSON_ENCODING, JSON_PAYLOAD.getBytes(Charset.defaultCharset()))) .setCode(CODE) .build()))) .setExtras(destination.getExtras()) .build(); } @Test public void testCCTSuccessLoggingRequest() { stubFor( post(urlEqualTo("/api")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;hello=world") .withBody("{\"nextRequestWaitMillis\":3}"))); BackendRequest backendRequest = getCCTBackendRequest(); wallClock.tick(); uptimeClock.tick(); BackendResponse response = BACKEND.send(backendRequest); ConnectivityManager connectivityManager = (ConnectivityManager) RuntimeEnvironment.application.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); verify( postRequestedFor(urlEqualTo("/api")) .withHeader( "User-Agent", equalTo(String.format("datatransport/%s android/", BuildConfig.VERSION_NAME))) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(matchingJsonPath("$[?(@.logRequest.size() == 1)]")) .withRequestBody( matchingJsonPath( String.format( "$[?(@.logRequest[0].requestTimeMs == %s)]", wallClock.getTime()))) .withRequestBody( matchingJsonPath( String.format( "$[?(@.logRequest[0].requestUptimeMs == %s)]", uptimeClock.getTime()))) .withRequestBody(matchingJsonPath("$[?(@.logRequest[0].logEvent.size() == 2)]")) .withRequestBody( matchingJsonPath( String.format( "$[?(@.logRequest[0].logEvent[0].eventTimeMs == \"%s\")]", INITIAL_WALL_TIME))) .withRequestBody( matchingJsonPath( String.format( "$[?(@.logRequest[0].logEvent[0].eventUptimeMs == \"%s\")]", INITIAL_UPTIME))) .withRequestBody( matchingJsonPath( String.format( "$[?(@.logRequest[0].logEvent[0].sourceExtension == \"%s\")]", PAYLOAD_BYTE64))) .withRequestBody( matchingJsonPath( String.format( "$[?(@.logRequest[0].logEvent[0].timezoneOffsetSeconds == \"%s\")]", getTzOffset()))) .withRequestBody( matchingJsonPath( String.format( "$[?(@.logRequest[0].logEvent[0].networkConnectionInfo.networkType == \"%s\")]", NetworkConnectionInfo.NetworkType.forNumber(activeNetworkInfo.getType())))) .withRequestBody( matchingJsonPath( String.format( "$[?(@.logRequest[0].logEvent[0].networkConnectionInfo.mobileSubtype == \"%s\")]", NetworkConnectionInfo.MobileSubtype.forNumber( activeNetworkInfo.getSubtype())))) .withRequestBody(notMatching("$[?(@.logRequest[0].logEvent[0].eventCode)]")) .withRequestBody(matchingJsonPath("$[?(@.logRequest[0].logEvent[1].eventCode == 5)]")) .withRequestBody( matchingJsonPath( String.format( "$[?(@.logRequest[0].logEvent[1].sourceExtensionJsonProto3 == \"%s\")]", JSON_PAYLOAD_ESCAPED)))); assertEquals(BackendResponse.ok(3), response); } @Test public void testLegacyFlgSuccessLoggingRequest_containsAPIKey() { stubFor( post(urlEqualTo("/api")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;hello=world") .withBody("{\"nextRequestWaitMillis\":3}"))); wallClock.tick(); uptimeClock.tick(); BACKEND.send( getCCTBackendRequest(LEGACY_TRANSPORT_NAME, new CCTDestination(TEST_ENDPOINT, API_KEY))); verify( postRequestedFor(urlEqualTo("/api")) .withHeader(CctTransportBackend.API_KEY_HEADER_KEY, equalTo(API_KEY))); } @Test public void testLegacyFlgSuccessLoggingRequest_containUrl() { final String customHostname = "http://localhost:8999"; stubFor( post(urlEqualTo("/custom_api")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;hello=world") .withBody("{\"nextRequestWaitMillis\":3}"))); wallClock.tick(); uptimeClock.tick(); BACKEND.send( getCCTBackendRequest( LEGACY_TRANSPORT_NAME, new CCTDestination(customHostname + "/custom_api", null))); verify( postRequestedFor(urlEqualTo("/custom_api")) .withHeader(CctTransportBackend.API_KEY_HEADER_KEY, absent())); } @Test public void testLegacyFlgSuccessLoggingRequest_containsAPIKeyAndUrl() { final String customHostname = "http://localhost:8999"; stubFor( post(urlEqualTo("/custom_api")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;hello=world") .withBody("{\"nextRequestWaitMillis\":3}"))); wallClock.tick(); uptimeClock.tick(); BACKEND.send( getCCTBackendRequest( LEGACY_TRANSPORT_NAME, new CCTDestination(customHostname + "/custom_api", API_KEY))); verify( postRequestedFor(urlEqualTo("/custom_api")) .withHeader(CctTransportBackend.API_KEY_HEADER_KEY, equalTo(API_KEY))); } @Test public void testLegacyFlgSuccessLoggingRequest_corruptedExtras() throws UnsupportedEncodingException { BackendRequest request = BackendRequest.builder() .setEvents( Arrays.asList( BACKEND.decorate( EventInternal.builder() .setEventMillis(INITIAL_WALL_TIME) .setUptimeMillis(INITIAL_UPTIME) .setTransportName("4") .setEncodedPayload( new EncodedPayload(PROTOBUF_ENCODING, PAYLOAD.toByteArray())) .build()), BACKEND.decorate( EventInternal.builder() .setEventMillis(INITIAL_WALL_TIME) .setUptimeMillis(INITIAL_UPTIME) .setTransportName("4") .setEncodedPayload( new EncodedPayload(PROTOBUF_ENCODING, PAYLOAD.toByteArray())) .setCode(CODE) .build()))) .setExtras("not a valid extras".getBytes("UTF-8")) .build(); stubFor( post(urlEqualTo("/api")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;hello=world") .withBody("{\"nextRequestWaitMillis\":3}"))); wallClock.tick(); uptimeClock.tick(); BackendResponse response = BACKEND.send(request); assertThat(response.getStatus()).isEqualTo(BackendResponse.Status.FATAL_ERROR); } @Test public void testUnsuccessfulLoggingRequest() { stubFor(post(urlEqualTo("/api")).willReturn(aResponse().withStatus(404))); BackendResponse response = BACKEND.send(getCCTBackendRequest()); verify( postRequestedFor(urlEqualTo("/api")) .withHeader("Content-Type", equalTo("application/json"))); assertEquals(BackendResponse.transientError(), response); } @Test public void testServerErrorLoggingRequest() { stubFor(post(urlEqualTo("/api")).willReturn(aResponse().withStatus(500))); BackendResponse response = BACKEND.send(getCCTBackendRequest()); verify( postRequestedFor(urlEqualTo("/api")) .withHeader("Content-Type", equalTo("application/json"))); assertEquals(BackendResponse.transientError(), response); } @Test public void testGarbageFromServer() { stubFor( post(urlEqualTo("/api")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;hello=world") .withBody("{\"status\":\"Error\",\"message\":\"Endpoint not found\"}"))); BackendResponse response = BACKEND.send(getCCTBackendRequest()); verify( postRequestedFor(urlEqualTo("/api")) .withHeader("Content-Type", equalTo("application/json"))); assertEquals(BackendResponse.transientError(), response); } @Test public void testNonHandledResponseCode() { stubFor(post(urlEqualTo("/api")).willReturn(aResponse().withStatus(300))); BackendResponse response = BACKEND.send(getCCTBackendRequest()); verify( postRequestedFor(urlEqualTo("/api")) .withHeader("Content-Type", equalTo("application/json"))); assertEquals(BackendResponse.fatalError(), response); } @Test public void send_whenBackendResponseTimesOut_shouldReturnTransientError() { CctTransportBackend backend = new CctTransportBackend(RuntimeEnvironment.application, wallClock, uptimeClock, 300); stubFor(post(urlEqualTo("/api")).willReturn(aResponse().withFixedDelay(500))); BackendResponse response = backend.send(getCCTBackendRequest()); assertEquals(BackendResponse.transientError(), response); } @Test public void decorate_whenOnline_shouldProperlyPopulateNetworkInfo() { CctTransportBackend backend = new CctTransportBackend(RuntimeEnvironment.application, wallClock, uptimeClock, 300); EventInternal result = backend.decorate( EventInternal.builder() .setEventMillis(INITIAL_WALL_TIME) .setUptimeMillis(INITIAL_UPTIME) .setTransportName("3") .setEncodedPayload(new EncodedPayload(PROTOBUF_ENCODING, PAYLOAD.toByteArray())) .build()); assertThat(result.get(CctTransportBackend.KEY_NETWORK_TYPE)) .isEqualTo(String.valueOf(NetworkConnectionInfo.NetworkType.MOBILE.getValue())); assertThat(result.get(CctTransportBackend.KEY_MOBILE_SUBTYPE)) .isEqualTo(String.valueOf(NetworkConnectionInfo.MobileSubtype.EDGE.getValue())); } @Test @Config(shadows = {OfflineConnectivityManagerShadow.class}) public void decorate_whenOffline_shouldProperlyPopulateNetworkInfo() { CctTransportBackend backend = new CctTransportBackend(RuntimeEnvironment.application, wallClock, uptimeClock, 300); EventInternal result = backend.decorate( EventInternal.builder() .setEventMillis(INITIAL_WALL_TIME) .setUptimeMillis(INITIAL_UPTIME) .setTransportName("3") .setEncodedPayload(new EncodedPayload(PROTOBUF_ENCODING, PAYLOAD.toByteArray())) .build()); assertThat(result.get(CctTransportBackend.KEY_NETWORK_TYPE)) .isEqualTo(String.valueOf(NetworkConnectionInfo.NetworkType.NONE.getValue())); assertThat(result.get(CctTransportBackend.KEY_MOBILE_SUBTYPE)) .isEqualTo( String.valueOf(NetworkConnectionInfo.MobileSubtype.UNKNOWN_MOBILE_SUBTYPE.getValue())); } @Test public void send_whenBackendRedirects_shouldCorrectlyFollowTheRedirectViaPost() { stubFor( post(urlEqualTo("/api")) .willReturn( aResponse().withStatus(302).withHeader("Location", TEST_ENDPOINT + "/hello"))); stubFor( post(urlEqualTo("/api/hello")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;hello=world") .withBody("{\"nextRequestWaitMillis\":3}"))); BackendRequest backendRequest = getCCTBackendRequest(); wallClock.tick(); uptimeClock.tick(); BackendResponse response = BACKEND.send(backendRequest); verify( postRequestedFor(urlEqualTo("/api")) .withHeader("Content-Type", equalTo("application/json"))); verify( postRequestedFor(urlEqualTo("/api/hello")) .withHeader("Content-Type", equalTo("application/json"))); assertEquals(BackendResponse.ok(3), response); } @Test public void send_whenBackendRedirectswith307_shouldCorrectlyFollowTheRedirectViaPost() { stubFor( post(urlEqualTo("/api")) .willReturn( aResponse().withStatus(307).withHeader("Location", TEST_ENDPOINT + "/hello"))); stubFor( post(urlEqualTo("/api/hello")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;hello=world") .withBody("{\"nextRequestWaitMillis\":3}"))); BackendRequest backendRequest = getCCTBackendRequest(); wallClock.tick(); uptimeClock.tick(); BackendResponse response = BACKEND.send(backendRequest); verify( postRequestedFor(urlEqualTo("/api")) .withHeader("Content-Type", equalTo("application/json"))); verify( postRequestedFor(urlEqualTo("/api/hello")) .withHeader("Content-Type", equalTo("application/json"))); assertEquals(BackendResponse.ok(3), response); } @Test public void send_whenBackendRedirectsMoreThan5Times_shouldOnlyRedirect4Times() { stubFor( post(urlEqualTo("/api")) .willReturn( aResponse().withStatus(302).withHeader("Location", TEST_ENDPOINT + "/hello"))); stubFor( post(urlEqualTo("/api/hello")) .willReturn( aResponse().withStatus(302).withHeader("Location", TEST_ENDPOINT + "/hello"))); BackendRequest backendRequest = getCCTBackendRequest(); wallClock.tick(); uptimeClock.tick(); BackendResponse response = BACKEND.send(backendRequest); verify( postRequestedFor(urlEqualTo("/api")) .withHeader("Content-Type", equalTo("application/json"))); verify( 4, postRequestedFor(urlEqualTo("/api/hello")) .withHeader("Content-Type", equalTo("application/json"))); assertEquals(BackendResponse.fatalError(), response); } @Test public void send_CompressedResponseIsUncompressed() throws IOException { ByteArrayOutputStream output = new ByteArrayOutputStream(); GZIPOutputStream gzipOutputStream = new GZIPOutputStream(output); gzipOutputStream.write("{\"nextRequestWaitMillis\":3}".getBytes(Charset.forName("UTF-8"))); gzipOutputStream.close(); stubFor( post(urlEqualTo("/api")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;hello=world") .withHeader("Content-Encoding", "gzip") .withBody(output.toByteArray()))); BackendRequest backendRequest = getCCTBackendRequest(); wallClock.tick(); uptimeClock.tick(); BackendResponse response = BACKEND.send(backendRequest); verify( postRequestedFor(urlEqualTo("/api")) .withHeader("Content-Type", equalTo("application/json")) .withHeader("Content-Encoding", equalTo("gzip"))); assertEquals(BackendResponse.ok(3), response); } @Test public void send_whenLogSourceIsSetByName_shouldSetItToProperField() throws IOException { ByteArrayOutputStream output = new ByteArrayOutputStream(); GZIPOutputStream gzipOutputStream = new GZIPOutputStream(output); gzipOutputStream.write("{\"nextRequestWaitMillis\":3}".getBytes(Charset.forName("UTF-8"))); gzipOutputStream.close(); stubFor( post(urlEqualTo("/api")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;hello=world") .withHeader("Content-Encoding", "gzip") .withBody(output.toByteArray()))); BackendRequest backendRequest = BackendRequest.builder() .setEvents( Arrays.asList( BACKEND.decorate( EventInternal.builder() .setEventMillis(INITIAL_WALL_TIME) .setUptimeMillis(INITIAL_UPTIME) .setTransportName("3") .setEncodedPayload( new EncodedPayload(PROTOBUF_ENCODING, PAYLOAD.toByteArray())) .build()), BACKEND.decorate( EventInternal.builder() .setEventMillis(INITIAL_WALL_TIME) .setUptimeMillis(INITIAL_UPTIME) .setTransportName(TEST_NAME) .setEncodedPayload( new EncodedPayload(PROTOBUF_ENCODING, PAYLOAD.toByteArray())) .setCode(CODE) .build()))) .setExtras(new CCTDestination(TEST_ENDPOINT, null).getExtras()) .build(); wallClock.tick(); uptimeClock.tick(); BackendResponse response = BACKEND.send(backendRequest); verify( postRequestedFor(urlEqualTo("/api")) .withHeader("Content-Type", equalTo("application/json")) .withHeader("Content-Encoding", equalTo("gzip")) .withRequestBody(matchingJsonPath("$[?(@.logRequest.size() == 2)]")) .withRequestBody(matchingJsonPath("$[?(@.logRequest[0].logSource == 3)]")) .withRequestBody( matchingJsonPath( String.format("$[?(@.logRequest[1].logSourceName == \"%s\")]", TEST_NAME)))); assertEquals(BackendResponse.ok(3), response); } @Test public void send_withEventsOfUnsupportedEncoding_shouldBeSkipped() throws IOException { stubFor( post(urlEqualTo("/api")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json;charset=UTF8;") .withBody("{\"nextRequestWaitMillis\":3}"))); BackendRequest backendRequest = BackendRequest.builder() .setEvents( Arrays.asList( BACKEND.decorate( EventInternal.builder() .setEventMillis(INITIAL_WALL_TIME) .setUptimeMillis(INITIAL_UPTIME) .setTransportName("3") .setEncodedPayload( new EncodedPayload(Encoding.of("yaml"), PAYLOAD.toByteArray())) .build()), BACKEND.decorate( EventInternal.builder() .setEventMillis(INITIAL_WALL_TIME) .setUptimeMillis(INITIAL_UPTIME) .setTransportName(TEST_NAME) .setEncodedPayload( new EncodedPayload(PROTOBUF_ENCODING, PAYLOAD.toByteArray())) .setCode(CODE) .build()))) .setExtras(new CCTDestination(TEST_ENDPOINT, null).getExtras()) .build(); wallClock.tick(); uptimeClock.tick(); BackendResponse response = BACKEND.send(backendRequest); verify( postRequestedFor(urlEqualTo("/api")) .withHeader("Content-Type", equalTo("application/json")) .withHeader("Content-Encoding", equalTo("gzip")) .withRequestBody(matchingJsonPath("$[?(@.logRequest.size() == 2)]")) .withRequestBody(matchingJsonPath("$[?(@.logRequest[0].logSource == 3)]")) .withRequestBody(notMatching("$[?(@.logRequest[0].logEvent)]")) .withRequestBody( matchingJsonPath( String.format("$[?(@.logRequest[1].logSourceName == \"%s\")]", TEST_NAME))) .withRequestBody(matchingJsonPath("$[?(@.logRequest[1].logEvent.size() == 1)]"))); assertEquals(BackendResponse.ok(3), response); } // When there is no active network, the ConnectivityManager returns null when // getActiveNetworkInfo() is called. @Implements(ConnectivityManager.class) public static class OfflineConnectivityManagerShadow { @Implementation public NetworkInfo getActiveNetworkInfo() { return null; } } }