mirror of
https://github.com/signalapp/Signal-Server
synced 2026-02-15 09:25:40 +00:00
Add a system to look up mobile network data
This commit is contained in:
@@ -97,3 +97,6 @@ turn.cloudflare.apiToken: ABCDEFGHIJKLM
|
||||
linkDevice.secret: AAAAAAAAAAA=
|
||||
|
||||
tlsKeyStore.password: unset
|
||||
|
||||
hlrLookup.apiKey: AAAAAAAAAAA
|
||||
hlrLookup.apiSecret: AAAAAAAAAAA
|
||||
|
||||
@@ -528,3 +528,7 @@ callQualitySurvey:
|
||||
{
|
||||
"credential": "configuration"
|
||||
}
|
||||
|
||||
hlrLookup:
|
||||
apiKey: secret://hlrLookup.apiKey
|
||||
apiSecret: secret://hlrLookup.apiSecret
|
||||
|
||||
@@ -39,6 +39,7 @@ import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguratio
|
||||
import org.whispersystems.textsecuregcm.configuration.GenericZkConfig;
|
||||
import org.whispersystems.textsecuregcm.configuration.GooglePlayBillingConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.GrpcConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.HlrLookupConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.IdlePrimaryDeviceReminderConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration;
|
||||
@@ -332,6 +333,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private Map<String, @Valid RetryConfiguration> retries = Collections.emptyMap();
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private HlrLookupConfiguration hlrLookup;
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -590,4 +596,8 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
public CallQualitySurveyConfiguration getCallQualitySurveyConfiguration() {
|
||||
return callQualitySurvey;
|
||||
}
|
||||
|
||||
public HlrLookupConfiguration getHlrLookupConfiguration() {
|
||||
return hlrLookup;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,8 @@ import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.telephony.CarrierDataProvider;
|
||||
import org.whispersystems.textsecuregcm.telephony.hlrlookup.HlrLookupCarrierDataProvider;
|
||||
import org.whispersystems.textsecuregcm.util.BufferingInterceptor;
|
||||
import org.whispersystems.textsecuregcm.util.ManagedAwsCrt;
|
||||
import org.whispersystems.textsecuregcm.util.ManagedExecutors;
|
||||
@@ -580,6 +582,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
.maxThreads(2)
|
||||
.minThreads(2)
|
||||
.build();
|
||||
ExecutorService hlrLookupHttpExecutor = ExecutorServiceBuilder.of(environment, "hlrLookup")
|
||||
.maxThreads(2)
|
||||
.minThreads(2)
|
||||
.build();
|
||||
|
||||
ExecutorService subscriptionProcessorExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(
|
||||
"subscriptionProcessor",
|
||||
@@ -634,6 +640,14 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords);
|
||||
UsernameHashZkProofVerifier usernameHashZkProofVerifier = new UsernameHashZkProofVerifier();
|
||||
|
||||
final CarrierDataProvider carrierDataProvider =
|
||||
new HlrLookupCarrierDataProvider(config.getHlrLookupConfiguration().apiKey().value(),
|
||||
config.getHlrLookupConfiguration().apiSecret().value(),
|
||||
hlrLookupHttpExecutor,
|
||||
config.getHlrLookupConfiguration().circuitBreakerConfigurationName(),
|
||||
config.getHlrLookupConfiguration().retryConfigurationName(),
|
||||
retryExecutor);
|
||||
|
||||
RegistrationServiceClient registrationServiceClient = config.getRegistrationServiceConfiguration()
|
||||
.build(environment, registrationCallbackExecutor, registrationIdentityTokenRefreshExecutor);
|
||||
KeyTransparencyServiceClient keyTransparencyServiceClient = new KeyTransparencyServiceClient(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public record HlrLookupConfiguration(SecretString apiKey,
|
||||
SecretString apiSecret,
|
||||
@Nullable String circuitBreakerConfigurationName,
|
||||
@Nullable String retryConfigurationName) {
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.google.common.annotations.VisibleForTesting;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryConfig;
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
@@ -18,6 +19,7 @@ import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executor;
|
||||
@@ -29,8 +31,8 @@ import java.util.stream.IntStream;
|
||||
import javax.annotation.Nullable;
|
||||
import org.glassfish.jersey.SslConfigurator;
|
||||
import org.whispersystems.textsecuregcm.util.CertificateUtil;
|
||||
import org.whispersystems.textsecuregcm.util.ResilienceUtil;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.ResilienceUtil;
|
||||
|
||||
public class FaultTolerantHttpClient {
|
||||
|
||||
@@ -65,17 +67,31 @@ public class FaultTolerantHttpClient {
|
||||
return this.httpClients.get(ThreadLocalRandom.current().nextInt(this.httpClients.size()));
|
||||
}
|
||||
|
||||
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request,
|
||||
public <T> HttpResponse<T> send(final HttpRequest request, final HttpResponse.BodyHandler<T> bodyHandler)
|
||||
throws IOException {
|
||||
final Callable<HttpResponse<T>> requestCallable =
|
||||
() -> httpClient().send(requestWithTimeout(request, defaultRequestTimeout), bodyHandler);
|
||||
|
||||
try {
|
||||
return retry != null
|
||||
? breaker.executeCallable(retry.decorateCallable(requestCallable))
|
||||
: breaker.executeCallable(requestCallable);
|
||||
} catch (final IOException e) {
|
||||
throw e;
|
||||
} catch (final Exception e) {
|
||||
if (e instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public <T> CompletableFuture<HttpResponse<T>> sendAsync(final HttpRequest request,
|
||||
final HttpResponse.BodyHandler<T> bodyHandler) {
|
||||
|
||||
if (request.timeout().isEmpty()) {
|
||||
request = HttpRequest.newBuilder(request, (_, _) -> true)
|
||||
.timeout(defaultRequestTimeout)
|
||||
.build();
|
||||
}
|
||||
|
||||
final Supplier<CompletionStage<HttpResponse<T>>> asyncRequestSupplier =
|
||||
sendAsync(httpClient(), request, bodyHandler);
|
||||
() -> httpClient().sendAsync(requestWithTimeout(request, defaultRequestTimeout), bodyHandler);
|
||||
|
||||
if (retry != null) {
|
||||
assert retryExecutor != null;
|
||||
@@ -87,10 +103,12 @@ public class FaultTolerantHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Supplier<CompletionStage<HttpResponse<T>>> sendAsync(HttpClient client, HttpRequest request,
|
||||
HttpResponse.BodyHandler<T> bodyHandler) {
|
||||
|
||||
return () -> client.sendAsync(request, bodyHandler);
|
||||
private static HttpRequest requestWithTimeout(final HttpRequest request, final Duration defaultRequestTimeout) {
|
||||
return request.timeout().isPresent()
|
||||
? request
|
||||
: HttpRequest.newBuilder(request, (_, _) -> true)
|
||||
.timeout(defaultRequestTimeout)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
@@ -102,7 +120,7 @@ public class FaultTolerantHttpClient {
|
||||
private int numClients = 1;
|
||||
|
||||
private final String name;
|
||||
private Executor executor;
|
||||
private final Executor executor;
|
||||
private KeyStore trustStore;
|
||||
private String securityProtocol = SECURITY_PROTOCOL_TLS_1_2;
|
||||
private String retryConfigurationName;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.telephony;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/// Line type and home network information for a specific phone number.
|
||||
///
|
||||
/// @param carrierName the name of the network operator for the specified phone number
|
||||
/// @param lineType the line type for the specified phone number
|
||||
/// @param mcc the mobile country code (MCC) of the phone number's home network if known; may be empty if the phone
|
||||
/// number is not a mobile number
|
||||
/// @param mnc the mobile network code (MNC) of the phone number's home network if known; may be empty if the phone
|
||||
/// number is not a mobile number
|
||||
public record CarrierData(String carrierName, LineType lineType, Optional<String> mcc, Optional<String> mnc) {
|
||||
|
||||
public enum LineType {
|
||||
MOBILE,
|
||||
LANDLINE,
|
||||
FIXED_VOIP,
|
||||
NON_FIXED_VOIP,
|
||||
OTHER,
|
||||
UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.telephony;
|
||||
|
||||
/// Indicates that a request for carrier data failed permanently (e.g. it was affirmatively rejected by the provider)
|
||||
/// and should not be retried without modification.
|
||||
public class CarrierDataException extends Exception {
|
||||
|
||||
public CarrierDataException(final String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.telephony;
|
||||
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/// A carrier data provider returns line type and home network information about a specific phone number. Carrier data
|
||||
/// providers may cache results and return cached results if they are newer than a given maximum age.
|
||||
public interface CarrierDataProvider {
|
||||
|
||||
/// Retrieves carrier data for a given phone number.
|
||||
///
|
||||
/// @param phoneNumber the phone number for which to retrieve line type and home network information
|
||||
/// @param maxCachedAge the maximum age of a cached response to return; providers must attempt to fetch fresh data if
|
||||
/// cached data is older than the given maximum age, and may choose to fetch fresh data under any circumstances
|
||||
///
|
||||
/// @return line type and home network information for the given phone number if available or empty if this provider
|
||||
/// could not find information for the given phone number
|
||||
///
|
||||
/// @throws IOException if the provider could not be reached due to a network problem of any kind
|
||||
/// @throws CarrierDataException if the request failed and should not be retried without modification
|
||||
Optional<CarrierData> lookupCarrierData(Phonenumber.PhoneNumber phoneNumber, Duration maxCachedAge)
|
||||
throws IOException, CarrierDataException;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.telephony.hlrlookup;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import javax.annotation.Nullable;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.telephony.CarrierData;
|
||||
import org.whispersystems.textsecuregcm.telephony.CarrierDataException;
|
||||
import org.whispersystems.textsecuregcm.telephony.CarrierDataProvider;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
/// A carrier data provider that uses [HLR Lookup](https://www.hlrlookup.com/) as its data source.
|
||||
public class HlrLookupCarrierDataProvider implements CarrierDataProvider {
|
||||
|
||||
private final String apiKey;
|
||||
private final String apiSecret;
|
||||
|
||||
private final FaultTolerantHttpClient httpClient;
|
||||
private final URI lookupUri;
|
||||
|
||||
private static final URI HLR_LOOKUP_URI = URI.create("https://api.hlrlookup.com/apiv2/hlr");
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = SystemMapper.jsonMapper();
|
||||
|
||||
private static final String REQUEST_COUNTER_NAME = MetricsUtil.name(HlrLookupCarrierDataProvider.class, "request");
|
||||
|
||||
public HlrLookupCarrierDataProvider(final String apiKey,
|
||||
final String apiSecret,
|
||||
final Executor httpRequestExecutor,
|
||||
@Nullable final String circuitBreakerConfigurationName,
|
||||
@Nullable final String retryConfigurationName,
|
||||
final ScheduledExecutorService retryExecutor) {
|
||||
|
||||
this(apiKey,
|
||||
apiSecret,
|
||||
FaultTolerantHttpClient.newBuilder("hlr-lookup", httpRequestExecutor)
|
||||
.withCircuitBreaker(circuitBreakerConfigurationName)
|
||||
.withRetry(retryConfigurationName, retryExecutor)
|
||||
.build(),
|
||||
HLR_LOOKUP_URI);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
HlrLookupCarrierDataProvider(final String apiKey,
|
||||
final String apiSecret,
|
||||
final FaultTolerantHttpClient httpClient,
|
||||
final URI lookupUri) {
|
||||
|
||||
this.apiKey = apiKey;
|
||||
this.apiSecret = apiSecret;
|
||||
this.httpClient = httpClient;
|
||||
this.lookupUri = lookupUri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<CarrierData> lookupCarrierData(final Phonenumber.PhoneNumber phoneNumber,
|
||||
final Duration maxCachedAge) throws IOException, CarrierDataException {
|
||||
|
||||
final HlrLookupResponse response;
|
||||
|
||||
try {
|
||||
final String requestJson = OBJECT_MAPPER.writeValueAsString(
|
||||
new HlrLookupRequest(apiKey, apiSecret,
|
||||
List.of(TelephoneNumberRequest.forPhoneNumber(phoneNumber, maxCachedAge))));
|
||||
|
||||
final HttpRequest request = HttpRequest
|
||||
.newBuilder(lookupUri)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
|
||||
.header("Content-Type", "application/json")
|
||||
.build();
|
||||
|
||||
final HttpResponse<String> httpResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (httpResponse.statusCode() != 200) {
|
||||
// We may or may not have helpful data in the response body
|
||||
try {
|
||||
final HlrLookupResponse hlrLookupResponse = parseResponse(httpResponse.body());
|
||||
|
||||
if (StringUtils.isNotBlank(hlrLookupResponse.error()) || StringUtils.isNotBlank(
|
||||
hlrLookupResponse.message())) {
|
||||
throw new CarrierDataException(
|
||||
"Received a non-success status code (%d): error = %s; message = %s".formatted(
|
||||
httpResponse.statusCode(), hlrLookupResponse.error(), hlrLookupResponse.message()));
|
||||
}
|
||||
} catch (final JsonProcessingException _) {
|
||||
// We couldn't parse the body, so just move on with the default error message
|
||||
}
|
||||
|
||||
throw new CarrierDataException("Received a non-success status code (%d)".formatted(httpResponse.statusCode()));
|
||||
}
|
||||
|
||||
response = parseResponse(httpResponse.body());
|
||||
|
||||
Metrics.counter(REQUEST_COUNTER_NAME, "status", String.valueOf(httpResponse.statusCode()))
|
||||
.increment();
|
||||
} catch (final IOException | CarrierDataException e) {
|
||||
Metrics.counter(REQUEST_COUNTER_NAME, "exception", e.getClass().getSimpleName())
|
||||
.increment();
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (response.results() == null || response.results().isEmpty()) {
|
||||
throw new CarrierDataException("No error reported, but no results provided");
|
||||
}
|
||||
|
||||
final HlrLookupResult result = response.results().getFirst();
|
||||
|
||||
if (!result.error().equals("NONE")) {
|
||||
throw new CarrierDataException("Received a per-number error: " + result.error());
|
||||
}
|
||||
|
||||
return getNetworkDetails(result)
|
||||
.map(networkDetails -> new CarrierData(
|
||||
networkDetails.name(),
|
||||
lineType(result.telephoneNumberType()),
|
||||
mccFromMccMnc(networkDetails.mccmnc()),
|
||||
mncFromMccMnc(networkDetails.mccmnc())));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static Optional<String> mccFromMccMnc(@Nullable final String mccMnc) {
|
||||
// MCCs are always 3 digits
|
||||
return Optional.ofNullable(StringUtils.stripToNull(mccMnc))
|
||||
.map(trimmedMccMnc -> trimmedMccMnc.substring(0, 3));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static Optional<String> mncFromMccMnc(@Nullable final String mccMnc) {
|
||||
// MNCs may be 2 or 3 digits, but always come after a 3-digit MCC
|
||||
return Optional.ofNullable(StringUtils.stripToNull(mccMnc))
|
||||
.map(trimmedMccMnc -> trimmedMccMnc.substring(3));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static CarrierData.LineType lineType(@Nullable final String telephoneNumberType) {
|
||||
if (telephoneNumberType == null) {
|
||||
return CarrierData.LineType.UNKNOWN;
|
||||
}
|
||||
|
||||
return switch (telephoneNumberType) {
|
||||
case "MOBILE" -> CarrierData.LineType.MOBILE;
|
||||
case "LANDLINE", "MOBILE_OR_LANDLINE" -> CarrierData.LineType.LANDLINE;
|
||||
case "VOIP" -> CarrierData.LineType.NON_FIXED_VOIP;
|
||||
case "UNKNOWN" -> CarrierData.LineType.UNKNOWN;
|
||||
default -> CarrierData.LineType.OTHER;
|
||||
};
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static Optional<NetworkDetails> getNetworkDetails(final HlrLookupResult hlrLookupResult) {
|
||||
if (hlrLookupResult.currentNetwork().equals("AVAILABLE")) {
|
||||
return Optional.of(hlrLookupResult.currentNetworkDetails());
|
||||
} else if (hlrLookupResult.originalNetwork().equals("AVAILABLE")) {
|
||||
return Optional.of(hlrLookupResult.originalNetworkDetails());
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static HlrLookupResponse parseResponse(final String responseJson) throws JsonProcessingException {
|
||||
return OBJECT_MAPPER.readValue(responseJson, HlrLookupResponse.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.telephony.hlrlookup;
|
||||
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||
import java.util.List;
|
||||
|
||||
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
|
||||
record HlrLookupRequest(String apiKey, String apiSecret, List<TelephoneNumberRequest> requests) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.telephony.hlrlookup;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.List;
|
||||
|
||||
record HlrLookupResponse(@Nullable List<HlrLookupResult> results,
|
||||
@Nullable String error,
|
||||
@Nullable String message) {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.telephony.hlrlookup;
|
||||
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||
|
||||
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
|
||||
record HlrLookupResult(String error,
|
||||
String originalNetwork,
|
||||
NetworkDetails originalNetworkDetails,
|
||||
String currentNetwork,
|
||||
NetworkDetails currentNetworkDetails,
|
||||
String telephoneNumberType) {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.telephony.hlrlookup;
|
||||
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||
|
||||
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
|
||||
record NetworkDetails(String name,
|
||||
String mccmnc,
|
||||
String countryName,
|
||||
String countryIso3,
|
||||
String area,
|
||||
String countryPrefix) {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.telephony.hlrlookup;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import javax.annotation.Nullable;
|
||||
import java.time.Duration;
|
||||
|
||||
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
|
||||
record TelephoneNumberRequest(String telephoneNumber,
|
||||
@Nullable Integer cacheDaysGlobal,
|
||||
@Nullable Integer cacheDaysPrivate,
|
||||
@JsonProperty("save_to_cache") String saveToGlobalCache) {
|
||||
|
||||
static TelephoneNumberRequest forPhoneNumber(final Phonenumber.PhoneNumber phoneNumber, final Duration maxCachedAge) {
|
||||
|
||||
return new TelephoneNumberRequest(
|
||||
StringUtils.stripStart(PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164), "+"),
|
||||
(int) maxCachedAge.toDays(),
|
||||
(int) maxCachedAge.toDays(),
|
||||
"NO");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -172,3 +172,6 @@ turn.cloudflare.apiToken: ABCDEFGHIJKLM
|
||||
linkDevice.secret: AAAAAAAAAAA=
|
||||
|
||||
tlsKeyStore.password: unset
|
||||
|
||||
hlrLookup.apiKey: AAAAAAAAAAA
|
||||
hlrLookup.apiSecret: AAAAAAAAAAA
|
||||
|
||||
@@ -527,3 +527,7 @@ asnTable:
|
||||
callQualitySurvey:
|
||||
pubSubPublisher:
|
||||
type: stub
|
||||
|
||||
hlrLookup:
|
||||
apiKey: secret://hlrLookup.apiKey
|
||||
apiSecret: secret://hlrLookup.apiSecret
|
||||
|
||||
Reference in New Issue
Block a user