Add a system to look up mobile network data

This commit is contained in:
Jon Chambers
2026-01-23 11:51:14 -05:00
committed by GitHub
parent 023296feaf
commit 3b87527f39
19 changed files with 933 additions and 62 deletions

View File

@@ -47,7 +47,7 @@ import org.whispersystems.textsecuregcm.util.ResilienceUtil;
class FaultTolerantHttpClientTest {
@RegisterExtension
private final WireMockExtension wireMock = WireMockExtension.newInstance()
private static final WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort().dynamicHttpsPort())
.build();
@@ -60,32 +60,34 @@ class FaultTolerantHttpClientTest {
retryExecutor = Executors.newSingleThreadScheduledExecutor();
}
@SuppressWarnings("ResultOfMethodCallIgnored")
@AfterEach
void tearDown() throws InterruptedException {
httpExecutor.shutdown();
httpExecutor.awaitTermination(1, TimeUnit.SECONDS);
retryExecutor.shutdown();
retryExecutor.awaitTermination(1, TimeUnit.SECONDS);
}
@Test
void testSimpleGet() {
void testSimpleGetAsync() {
wireMock.stubFor(get(urlEqualTo("/ping"))
.willReturn(aResponse()
.withHeader("Content-Type", "text/plain")
.withBody("Pong!")));
FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testSimpleGet", httpExecutor)
final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testSimpleGet", httpExecutor)
.withRetry(null, retryExecutor)
.withVersion(HttpClient.Version.HTTP_2)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + wireMock.getPort() + "/ping"))
.GET()
.build();
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + wireMock.getPort() + "/ping"))
.GET()
.build();
HttpResponse<String> response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
final HttpResponse<String> response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).isEqualTo("Pong!");
@@ -94,24 +96,49 @@ class FaultTolerantHttpClientTest {
}
@Test
void testRetryGet() {
wireMock.stubFor(get(urlEqualTo("/failure"))
.willReturn(aResponse()
.withStatus(500)
.withHeader("Content-Type", "text/plain")
.withBody("Pong!")));
void testSimpleGetSync() throws IOException, InterruptedException {
wireMock.stubFor(get(urlEqualTo("/ping"))
.willReturn(aResponse()
.withHeader("Content-Type", "text/plain")
.withBody("Pong!")));
FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testRetryGet", httpExecutor)
final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testSimpleGet", httpExecutor)
.withRetry(null, retryExecutor)
.withVersion(HttpClient.Version.HTTP_2)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + wireMock.getPort() + "/failure"))
.GET()
.build();
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + wireMock.getPort() + "/ping"))
.GET()
.build();
HttpResponse<String> response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
final HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).isEqualTo("Pong!");
wireMock.verify(1, getRequestedFor(urlEqualTo("/ping")));
}
@Test
void testRetryGetAsync() {
wireMock.stubFor(get(urlEqualTo("/failure"))
.willReturn(aResponse()
.withStatus(500)
.withHeader("Content-Type", "text/plain")
.withBody("Pong!")));
final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testRetryGet", httpExecutor)
.withRetry(null, retryExecutor)
.withVersion(HttpClient.Version.HTTP_2)
.build();
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + wireMock.getPort() + "/failure"))
.GET()
.build();
final HttpResponse<String> response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
assertThat(response.statusCode()).isEqualTo(500);
assertThat(response.body()).isEqualTo("Pong!");
@@ -120,7 +147,33 @@ class FaultTolerantHttpClientTest {
}
@Test
void testRetryGetOnException() {
void testRetryGetSync() throws IOException, InterruptedException {
wireMock.stubFor(get(urlEqualTo("/failure"))
.willReturn(aResponse()
.withStatus(500)
.withHeader("Content-Type", "text/plain")
.withBody("Pong!")));
final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testRetryGet", httpExecutor)
.withRetry(null, retryExecutor)
.withVersion(HttpClient.Version.HTTP_2)
.build();
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + wireMock.getPort() + "/failure"))
.GET()
.build();
final HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(500);
assertThat(response.body()).isEqualTo("Pong!");
wireMock.verify(3, getRequestedFor(urlEqualTo("/failure")));
}
@Test
void testRetryGetAsyncOnException() {
final HttpClient mockHttpClient = mock(HttpClient.class);
final Retry retry = Retry.of("test", new RetryConfiguration().toRetryConfigBuilder()
@@ -137,20 +190,47 @@ class FaultTolerantHttpClientTest {
when(mockHttpClient.sendAsync(any(), any()))
.thenReturn(CompletableFuture.failedFuture(new IOException("test exception")));
HttpRequest request = HttpRequest.newBuilder()
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:1234/failure"))
.GET()
.build();
try {
client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
throw new AssertionError("Should have failed!");
} catch (CompletionException e) {
assertThat(e.getCause()).isInstanceOf(IOException.class);
}
assertThatThrownBy(() -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join())
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(IOException.class);
verify(mockHttpClient, times(3)).sendAsync(any(), any());
}
@Test
void testRetryGetSyncOnException() throws IOException, InterruptedException {
final HttpClient mockHttpClient = mock(HttpClient.class);
final Retry retry = Retry.of("test", new RetryConfiguration().toRetryConfigBuilder()
.retryOnException(throwable -> throwable instanceof IOException)
.build());
final FaultTolerantHttpClient client = new FaultTolerantHttpClient(
List.of(mockHttpClient),
Duration.ofSeconds(1),
retryExecutor,
retry,
CircuitBreaker.ofDefaults("test"));
when(mockHttpClient.send(any(), any()))
.thenThrow(IOException.class);
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:1234/failure"))
.GET()
.build();
assertThatThrownBy(() -> client.send(request, HttpResponse.BodyHandlers.ofString()))
.isInstanceOf(IOException.class);
verify(mockHttpClient, times(3)).send(any(), any());
}
@Test
void testMultipleClients() throws IOException, InterruptedException {
final HttpClient mockHttpClient1 = mock(HttpClient.class);
@@ -177,28 +257,31 @@ class FaultTolerantHttpClientTest {
.uri(URI.create("http://localhost:" + wireMock.getPort() + "/ping"))
.GET()
.build();
final HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
final AtomicInteger client1Calls = new AtomicInteger(0);
final AtomicInteger client2Calls = new AtomicInteger(0);
when(mockHttpClient1.sendAsync(any(), any()))
.thenAnswer(args -> {
client1Calls.incrementAndGet();
return CompletableFuture.completedFuture(response);
});
when(mockHttpClient2.sendAsync(any(), any()))
.thenAnswer(args -> {
client2Calls.incrementAndGet();
return CompletableFuture.completedFuture(response);
});
try (final HttpClient httpClient = HttpClient.newHttpClient()) {
final HttpResponse<Void> response = httpClient.send(request, HttpResponse.BodyHandlers.discarding());
final int numCalls = 100;
for (int i = 0; i < numCalls; i++) {
client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
final AtomicInteger client1Calls = new AtomicInteger(0);
final AtomicInteger client2Calls = new AtomicInteger(0);
when(mockHttpClient1.sendAsync(any(), any()))
.thenAnswer(_ -> {
client1Calls.incrementAndGet();
return CompletableFuture.completedFuture(response);
});
when(mockHttpClient2.sendAsync(any(), any()))
.thenAnswer(_ -> {
client2Calls.incrementAndGet();
return CompletableFuture.completedFuture(response);
});
final int numCalls = 100;
for (int i = 0; i < numCalls; i++) {
client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
}
assertThat(client2Calls.get()).isGreaterThan(0);
assertThat(client1Calls.get()).isGreaterThan(0);
assertThat(client1Calls.get() + client2Calls.get()).isEqualTo(numCalls);
}
assertThat(client2Calls.get()).isGreaterThan(0);
assertThat(client1Calls.get()).isGreaterThan(0);
assertThat(client1Calls.get() + client2Calls.get()).isEqualTo(numCalls);
}
@Test
@@ -215,7 +298,8 @@ class FaultTolerantHttpClientTest {
ResilienceUtil.getCircuitBreakerRegistry()
.addConfiguration(circuitBreakerConfigurationName, circuitBreakerConfiguration.toCircuitBreakerConfig());
final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testNetworkFailureCircuitBreaker", httpExecutor)
final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder("testNetworkFailureCircuitBreaker",
httpExecutor)
.withCircuitBreaker(circuitBreakerConfigurationName)
.withRetry(null, retryExecutor)
.withVersion(HttpClient.Version.HTTP_2)

View File

@@ -0,0 +1,364 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.telephony.hlrlookup;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import javax.annotation.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
import org.whispersystems.textsecuregcm.telephony.CarrierData;
import org.whispersystems.textsecuregcm.telephony.CarrierDataException;
class HlrLookupCarrierDataProviderTest {
private HlrLookupCarrierDataProvider hlrLookupCarrierDataProvider;
@RegisterExtension
private static final WireMockExtension WIRE_MOCK_EXTENSION = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort().dynamicHttpsPort())
.build();
private static final String HLR_LOOKUP_PATH = "/hlr";
@BeforeEach
void setUp() {
final FaultTolerantHttpClient faultTolerantHttpClient = FaultTolerantHttpClient.newBuilder("hlrLookupTest", Runnable::run)
.build();
hlrLookupCarrierDataProvider = new HlrLookupCarrierDataProvider("test", "test", faultTolerantHttpClient, URI.create("http://localhost:" + WIRE_MOCK_EXTENSION.getPort() + HLR_LOOKUP_PATH));
}
@Test
void lookupCarrierData() throws IOException, CarrierDataException {
final String responseJson = """
{
"results": [
{
"error": "NONE",
"uuid": "f066f711-4043-4d54-847d-c273e6491881",
"request_parameters": {
"telephone_number": "+44(7790) 60 60 23",
"save_to_cache": "YES",
"input_format": "",
"output_format": "",
"cache_days_global": 0,
"cache_days_private": 0,
"get_ported_date": "NO",
"get_landline_status": "NO",
"usa_status": "NO"
},
"credits_spent": 1,
"detected_telephone_number": "447790606023",
"formatted_telephone_number": "",
"live_status": "LIVE",
"original_network": "AVAILABLE",
"original_network_details": {
"name": "EE Limited (Orange)",
"mccmnc": "23433",
"country_name": "United Kingdom",
"country_iso3": "GBR",
"area": "United Kingdom",
"country_prefix": "44"
},
"current_network": "AVAILABLE",
"current_network_details": {
"name": "Virgin Mobile",
"mccmnc": "23438",
"country_name": "United Kingdom",
"country_iso3": "GBR",
"country_prefix": "44"
},
"is_ported": "YES",
"timestamp": "2022-09-08T10:56:03Z",
"telephone_number_type": "MOBILE",
"sms_email": "",
"mms_email": ""
}
]
}
""";
WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(responseJson)));
final Optional<CarrierData> maybeCarrierData =
hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber("US"), Duration.ZERO);
assertEquals(Optional.of(new CarrierData("Virgin Mobile", CarrierData.LineType.MOBILE, Optional.of("234"), Optional.of("38"))),
maybeCarrierData);
}
@Test
void lookupCarrierDataNonSuccessStatus() {
WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH))
.willReturn(aResponse()
.withStatus(500)));
assertThrows(CarrierDataException.class, () ->
hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber("US"), Duration.ZERO));
}
@Test
void lookupCarrierDataErrorMessage() {
final String responseJson = """
{ "error": "UNAUTHORIZED", "message": "Invalid api_key or api_secret" }
""";
WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(responseJson)));
assertThrows(CarrierDataException.class, () ->
hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber("US"), Duration.ZERO));
}
@Test
void lookupCarrierDataEmptyBody() {
WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{}")));
assertThrows(CarrierDataException.class, () ->
hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber("US"), Duration.ZERO));
}
@Test
void lookupCarrierDataPerNumberError() {
final String responseJson = """
{
"body": {
"results": [
{
"error": "INTERNAL_ERROR"
}
]
}
}
""";
WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(responseJson)));
assertThrows(CarrierDataException.class, () ->
hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber("US"), Duration.ZERO));
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@ParameterizedTest
@MethodSource
void mccFromMccMnc(@Nullable final String mccMnc, final Optional<String> expectedMcc) {
assertEquals(expectedMcc, HlrLookupCarrierDataProvider.mccFromMccMnc(mccMnc));
}
private static List<Arguments> mccFromMccMnc() {
return List.of(
Arguments.argumentSet("Null mccMnc string", null, Optional.empty()),
Arguments.argumentSet("Empty mccMnc string", "", Optional.empty()),
Arguments.argumentSet("Blank mccMnc string", " ", Optional.empty()),
Arguments.argumentSet("Two-digit MNC", "12345", Optional.of("123")),
Arguments.argumentSet("Three-digit MNC", "123456", Optional.of("123"))
);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@ParameterizedTest
@MethodSource
void mncFromMccMnc(@Nullable final String mccMnc, final Optional<String> expectedMnc) {
assertEquals(expectedMnc, HlrLookupCarrierDataProvider.mncFromMccMnc(mccMnc));
}
private static List<Arguments> mncFromMccMnc() {
return List.of(
Arguments.argumentSet("Null mccMnc string", null, Optional.empty()),
Arguments.argumentSet("Empty mccMnc string", "", Optional.empty()),
Arguments.argumentSet("Blank mccMnc string", " ", Optional.empty()),
Arguments.argumentSet("Two-digit MNC", "12345", Optional.of("45")),
Arguments.argumentSet("Three-digit MNC", "123456", Optional.of("456"))
);
}
@ParameterizedTest
@MethodSource
void lineType(@Nullable final String lineType, final CarrierData.LineType expectedLineType) {
assertEquals(expectedLineType, HlrLookupCarrierDataProvider.lineType(lineType));
}
private static List<Arguments> lineType() {
return List.of(
Arguments.argumentSet("Null line type", null, CarrierData.LineType.UNKNOWN)
);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@ParameterizedTest
@MethodSource
void getNetworkDetails(final HlrLookupResult hlrLookupResult, final Optional<NetworkDetails> expectedNetworkDetails) {
assertEquals(expectedNetworkDetails, HlrLookupCarrierDataProvider.getNetworkDetails(hlrLookupResult));
}
private static List<Arguments> getNetworkDetails() {
final NetworkDetails originalNetwork = new NetworkDetails(
"Original network",
"123456",
"United States of America",
"USA",
"United States of America",
"1");
final NetworkDetails currentNetwork = new NetworkDetails(
"Current network",
"654321",
"United States of America",
"USA",
"United States of America",
"1");
return List.of(
Arguments.argumentSet("Original and current network",
resultWithNetworkDetails(originalNetwork, currentNetwork),
Optional.of(currentNetwork)),
Arguments.argumentSet("Original network only",
resultWithNetworkDetails(originalNetwork, null),
Optional.of(originalNetwork)),
Arguments.argumentSet("Current network only",
resultWithNetworkDetails(null, currentNetwork),
Optional.of(currentNetwork)),
Arguments.argumentSet("No network details",
resultWithNetworkDetails(null, null),
Optional.empty())
);
}
private static HlrLookupResult resultWithNetworkDetails(@Nullable final NetworkDetails originalNetwork,
@Nullable final NetworkDetails currentNetwork) {
return new HlrLookupResult(null,
originalNetwork == null ? "NOT_AVAILABLE" : "AVAILABLE",
originalNetwork,
currentNetwork == null ? "NOT_AVAILABLE" : "AVAILABLE",
currentNetwork,
"MOBILE");
}
@Test
void parseResponse() throws JsonProcessingException {
final String json = """
{
"results": [
{
"error": "NONE",
"uuid": "f066f711-4043-4d54-847d-c273e6491881",
"request_parameters": {
"telephone_number": "+44(7790) 60 60 23",
"save_to_cache": "YES",
"input_format": "",
"output_format": "",
"cache_days_global": 0,
"cache_days_private": 0,
"get_ported_date": "NO",
"get_landline_status": "NO",
"usa_status": "NO"
},
"credits_spent": 1,
"detected_telephone_number": "447790606023",
"formatted_telephone_number": "",
"live_status": "LIVE",
"original_network": "AVAILABLE",
"original_network_details": {
"name": "EE Limited (Orange)",
"mccmnc": "23433",
"country_name": "United Kingdom",
"country_iso3": "GBR",
"area": "United Kingdom",
"country_prefix": "44"
},
"current_network": "AVAILABLE",
"current_network_details": {
"name": "Virgin Mobile",
"mccmnc": "23438",
"country_name": "United Kingdom",
"country_iso3": "GBR",
"country_prefix": "44"
},
"is_ported": "YES",
"timestamp": "2022-09-08T10:56:03Z",
"telephone_number_type": "MOBILE",
"sms_email": "",
"mms_email": ""
}
]
}
""";
final HlrLookupResponse response = HlrLookupCarrierDataProvider.parseResponse(json);
assertNull(response.error());
assertNull(response.message());
assertNotNull(response.results());
assertEquals(1, response.results().size());
final HlrLookupResult result = response.results().getFirst();
assertEquals("NONE", result.error());
assertEquals("MOBILE", result.telephoneNumberType());
assertEquals("AVAILABLE", result.originalNetwork());
assertEquals("EE Limited (Orange)", result.originalNetworkDetails().name());
assertEquals("23433", result.originalNetworkDetails().mccmnc());
assertEquals("United Kingdom", result.originalNetworkDetails().countryName());
assertEquals("GBR", result.originalNetworkDetails().countryIso3());
assertEquals("United Kingdom", result.originalNetworkDetails().area());
assertEquals("44", result.originalNetworkDetails().countryPrefix());
assertEquals("AVAILABLE", result.currentNetwork());
assertEquals("Virgin Mobile", result.currentNetworkDetails().name());
assertEquals("23438", result.currentNetworkDetails().mccmnc());
assertEquals("United Kingdom", result.currentNetworkDetails().countryName());
assertEquals("GBR", result.currentNetworkDetails().countryIso3());
assertNull(result.currentNetworkDetails().area());
assertEquals("44", result.currentNetworkDetails().countryPrefix());
}
@Test
void parseResponseError() throws JsonProcessingException {
final String json = """
{ "error": "UNAUTHORIZED", "message": "Invalid api_key or api_secret" }
""";
final HlrLookupResponse response = HlrLookupCarrierDataProvider.parseResponse(json);
assertEquals("UNAUTHORIZED", response.error());
assertEquals("Invalid api_key or api_secret", response.message());
assertNull(response.results());
}
}