diff --git a/.gitignore b/.gitignore index dfec95d..5558a8e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dependencies/ build/** .DS_Store */node_modules/* +.env \ No newline at end of file diff --git a/pom.xml b/pom.xml index 071a0e0..d69e08c 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.uid2 uid2-client - 4.6.0 + 4.6.8-alpha-29-SNAPSHOT ${project.groupId}:${project.artifactId} UID2 Client diff --git a/src/main/java/com/uid2/client/EnvelopeV2.java b/src/main/java/com/uid2/client/EnvelopeV2.java index 79557e4..5cc5fa2 100644 --- a/src/main/java/com/uid2/client/EnvelopeV2.java +++ b/src/main/java/com/uid2/client/EnvelopeV2.java @@ -2,8 +2,8 @@ public class EnvelopeV2 { - EnvelopeV2(String envelope, byte[] nonce) { - this.envelope = envelope; + EnvelopeV2(byte[] envelope, byte[] nonce) { + this.binaryEnvelope = envelope; this.nonce = nonce; } @@ -11,10 +11,14 @@ public class EnvelopeV2 { * @return an encrypted request envelope which can be used in the POST body of a UID2 endpoint. * See Encrypted Request Envelope */ - public String getEnvelope() { return envelope; } + public String getEnvelope() { return InputUtil.byteArrayToBase64(binaryEnvelope); } byte[] getNonce() { return nonce;} - private final String envelope; + public byte[] getBinaryEnvelope() { + return binaryEnvelope; + } + + private final byte[] binaryEnvelope; private final byte[] nonce; } diff --git a/src/main/java/com/uid2/client/IdentityMapClient.java b/src/main/java/com/uid2/client/IdentityMapClient.java index 41bd425..724d037 100644 --- a/src/main/java/com/uid2/client/IdentityMapClient.java +++ b/src/main/java/com/uid2/client/IdentityMapClient.java @@ -19,7 +19,7 @@ public IdentityMapClient(String uid2BaseUrl, String clientApiKey, String base64S public IdentityMapResponse generateIdentityMap(IdentityMapInput identityMapInput) { EnvelopeV2 envelope = identityMapHelper.createEnvelopeForIdentityMapRequest(identityMapInput); - String responseString = uid2ClientHelper.makeRequest(envelope, "/v2/identity/map"); + String responseString = uid2ClientHelper.makeRequest("/v2/identity/map", envelope).getAsString(); return identityMapHelper.createIdentityMapResponse(responseString, envelope, identityMapInput); } diff --git a/src/main/java/com/uid2/client/IdentityMapV3Client.java b/src/main/java/com/uid2/client/IdentityMapV3Client.java new file mode 100644 index 0000000..d6b6548 --- /dev/null +++ b/src/main/java/com/uid2/client/IdentityMapV3Client.java @@ -0,0 +1,32 @@ +package com.uid2.client; + +public class IdentityMapV3Client { + /** + * @param uid2BaseUrl The UID2 Base URL + * @param clientApiKey Your client API key + * @param base64SecretKey Your client secret key + */ + public IdentityMapV3Client(String uid2BaseUrl, String clientApiKey, String base64SecretKey) { + identityMapHelper = new IdentityMapV3Helper(base64SecretKey); + uid2ClientHelper = new Uid2ClientHelper(uid2BaseUrl, clientApiKey); + } + + /** + * @param identityMapInput represents the input required for /identity/map + * @return an IdentityMapV3Response instance + * @throws Uid2Exception if the response did not contain a "success" status, or the response code was not 200, or there was an error communicating with the provided UID2 Base URL + */ + public IdentityMapV3Response generateIdentityMap(IdentityMapV3Input identityMapInput) { + EnvelopeV2 envelope = identityMapHelper.createEnvelopeForIdentityMapRequest(identityMapInput); + + Uid2Response response = uid2ClientHelper.makeBinaryRequest("/v3/identity/map", envelope); + if (response.isBinary()) { + return identityMapHelper.createIdentityMapResponse(response.getAsBytes(), envelope, identityMapInput); + } else { + return identityMapHelper.createIdentityMapResponse(response.getAsString(), envelope, identityMapInput); + } + } + + private final IdentityMapV3Helper identityMapHelper; + private final Uid2ClientHelper uid2ClientHelper; +} diff --git a/src/main/java/com/uid2/client/IdentityMapV3Helper.java b/src/main/java/com/uid2/client/IdentityMapV3Helper.java new file mode 100644 index 0000000..705f17f --- /dev/null +++ b/src/main/java/com/uid2/client/IdentityMapV3Helper.java @@ -0,0 +1,40 @@ +package com.uid2.client; + +import com.google.gson.Gson; + +import java.nio.charset.StandardCharsets; + +public class IdentityMapV3Helper { + /** + * @param base64SecretKey your UID2 client secret + */ + public IdentityMapV3Helper(String base64SecretKey) {uid2Helper = new Uid2Helper(base64SecretKey);} + + /** + * @param identityMapInput represents the input required for /identity/map + * @return an EnvelopeV2 instance to use in the POST body of /identity/map + */ + public EnvelopeV2 createEnvelopeForIdentityMapRequest(IdentityMapV3Input identityMapInput) { + byte[] jsonBytes = new Gson().toJson(identityMapInput).getBytes(StandardCharsets.UTF_8); + return uid2Helper.createEnvelopeV2(jsonBytes); + } + + + /** + * @param responseString the response body returned by a call to /identity/map + * @param envelope the EnvelopeV2 instance returned by {@link #createEnvelopeForIdentityMapRequest} + * @param identityMapInput the same instance that was passed to {@link #createEnvelopeForIdentityMapRequest}. + * @return an IdentityMapV3Response instance + */ + public IdentityMapV3Response createIdentityMapResponse(String responseString, EnvelopeV2 envelope, IdentityMapV3Input identityMapInput) { + String decryptedResponseString = uid2Helper.decrypt(responseString, envelope.getNonce()); + return new IdentityMapV3Response(decryptedResponseString, identityMapInput); + } + + public IdentityMapV3Response createIdentityMapResponse(byte[] response, EnvelopeV2 envelope, IdentityMapV3Input identityMapInput) { + String decryptedResponseString = uid2Helper.decrypt(response, envelope.getNonce()); + return new IdentityMapV3Response(decryptedResponseString, identityMapInput); + } + + private final Uid2Helper uid2Helper; +} diff --git a/src/main/java/com/uid2/client/IdentityMapV3Input.java b/src/main/java/com/uid2/client/IdentityMapV3Input.java new file mode 100644 index 0000000..83ee82a --- /dev/null +++ b/src/main/java/com/uid2/client/IdentityMapV3Input.java @@ -0,0 +1,162 @@ +package com.uid2.client; + +import com.google.gson.annotations.SerializedName; + +import java.util.*; + +public class IdentityMapV3Input { + /** + * @param emails a list of normalized or unnormalized email addresses + * @return a IdentityMapV3Input instance, to be used in {@link IdentityMapV3Helper#createEnvelopeForIdentityMapRequest} + */ + public static IdentityMapV3Input fromEmails(List emails) { + return new IdentityMapV3Input().withEmails(emails); + } + + /** + * @param hashedEmails a normalized and hashed email address + * @return an IdentityMapV3Input instance + */ + public static IdentityMapV3Input fromHashedEmails(List hashedEmails) { + return new IdentityMapV3Input().withHashedEmails(hashedEmails); + } + + /** + * @param phones a normalized phone number + * @return an IdentityMapV3Input instance + */ + public static IdentityMapV3Input fromPhones(List phones) { + return new IdentityMapV3Input().withPhones(phones); + } + + /** + * @param hashedPhones a normalized and hashed phone number + * @return an IdentityMapV3Input instance + */ + public static IdentityMapV3Input fromHashedPhones(List hashedPhones) { + return new IdentityMapV3Input().withHashedPhones(hashedPhones); + } + + // Transient as this should not be part of the serialized JSON payload we send to UID2 Operator + private transient final Map> hashedDiiToRawDii = new HashMap<>(); + + @SerializedName("email_hash") + private final List hashedEmails = new ArrayList<>(); + + @SerializedName("phone_hash") + private final List hashedPhones = new ArrayList<>(); + + // We never send unhashed emails or phone numbers in the SDK, but they are required fields in the API request + @SerializedName("email") + private List emails = Collections.unmodifiableList(new ArrayList<>()); + @SerializedName("phone") + private List phones = Collections.unmodifiableList(new ArrayList<>()); + + public IdentityMapV3Input() {} + + /** + * @param hashedEmails a normalized and hashed email address + * @return this IdentityMapV3Input instance + */ + public IdentityMapV3Input withHashedEmails(List hashedEmails) { + for (String hashedEmail : hashedEmails) { + withHashedEmail(hashedEmail); + } + return this; + } + + /** + * @param hashedEmail a normalized and hashed email address + * @return this IdentityMapV3Input instance + */ + public IdentityMapV3Input withHashedEmail(String hashedEmail) { + this.hashedEmails.add(hashedEmail); + addToDiiMappings(hashedEmail, hashedEmail); + return this; + } + + /** + * @param hashedPhones a normalized and hashed phone number + * @return this IdentityMapV3Input instance + */ + public IdentityMapV3Input withHashedPhones(List hashedPhones) { + for (String hashedPhone : hashedPhones) { + withHashedPhone(hashedPhone); + } + return this; + } + + /** + * @param hashedPhone a normalized and hashed phone number + * @return this IdentityMapV3Input instance + */ + public IdentityMapV3Input withHashedPhone(String hashedPhone) { + this.hashedPhones.add(hashedPhone); + addToDiiMappings(hashedPhone, hashedPhone); + return this; + } + + /** + * @param emails a list of normalized or unnormalized email addresses + * @return this IdentityMapV3Input instance + */ + public IdentityMapV3Input withEmails(List emails) { + for (String email : emails) { + withEmail(email); + } + return this; + } + + /** + * @param email a normalized or unnormalized email address + * @return this IdentityMapV3Input instance + */ + public IdentityMapV3Input withEmail(String email) { + String hashedEmail = InputUtil.normalizeAndHashEmail(email); + this.hashedEmails.add(hashedEmail); + addToDiiMappings(hashedEmail, email); + return this; + } + + /** + * @param phones a normalized phone number + * @return this IdentityMapV3Input instance + */ + public IdentityMapV3Input withPhones(List phones) { + for (String phone : phones) { + withPhone(phone); + } + return this; + } + + /** + * @param phone a normalized phone number + * @return this IdentityMapV3Input instance + */ + public IdentityMapV3Input withPhone(String phone) { + if (!InputUtil.isPhoneNumberNormalized(phone)) { + throw new IllegalArgumentException("phone number is not normalized: " + phone); + } + + String hashedPhone = InputUtil.getBase64EncodedHash(phone); + this.hashedPhones.add(hashedPhone); + addToDiiMappings(hashedPhone, phone); + return this; + } + + List getInputDiis(String identityType, int i) { + return hashedDiiToRawDii.get(getHashedDii(identityType, i)); + } + + private void addToDiiMappings(String hashedDii, String rawDii) { + hashedDiiToRawDii.computeIfAbsent(hashedDii, k -> new ArrayList<>()).add(rawDii); + } + + private String getHashedDii(String identityType, int i) { + switch (identityType) { + case "email_hash": return hashedEmails.get(i); + case "phone_hash": return hashedPhones.get(i); + } + throw new Uid2Exception("Unexpected identity type: " + identityType); + } +} diff --git a/src/main/java/com/uid2/client/IdentityMapV3Response.java b/src/main/java/com/uid2/client/IdentityMapV3Response.java new file mode 100644 index 0000000..dc5f611 --- /dev/null +++ b/src/main/java/com/uid2/client/IdentityMapV3Response.java @@ -0,0 +1,128 @@ +package com.uid2.client; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class IdentityMapV3Response { + IdentityMapV3Response(String response, IdentityMapV3Input identityMapInput) { + ApiResponse apiResponse = new Gson().fromJson(response, ApiResponse.class); + status = apiResponse.status; + + if (!isSuccess()) { + throw new Uid2Exception("Got unexpected identity map status: " + status); + } + + populateIdentities(apiResponse.body, identityMapInput); + } + + private void populateIdentities(Map> apiResponse, IdentityMapV3Input identityMapInput) { + for (Map.Entry> identitiesForType : apiResponse.entrySet()) { + populateIdentitiesForType(identityMapInput, identitiesForType.getKey(), identitiesForType.getValue()); + } + } + + private void populateIdentitiesForType(IdentityMapV3Input identityMapInput, String identityType, List identities) { + for (int i = 0; i < identities.size(); i++) { + ApiIdentity apiIdentity = identities.get(i); + List inputDiis = identityMapInput.getInputDiis(identityType, i); + for (String inputDii : inputDiis) { + if (apiIdentity.error == null) { + mappedIdentities.put(inputDii, new MappedIdentity(apiIdentity)); + } else { + unmappedIdentities.put(inputDii, new UnmappedIdentity(apiIdentity.error)); + } + } + } + } + + public boolean isSuccess() { + return "success".equals(status); + } + + public static class ApiResponse { + @SerializedName("status") + public String status; + + @SerializedName("body") + public Map> body; + } + + public static class ApiIdentity { + @SerializedName("u") + public String currentUid; + + @SerializedName("p") + public String previousUid; + + @SerializedName("r") + public Long refreshFromSeconds; + + @SerializedName("e") + public String error; + } + + public static class MappedIdentity { + public MappedIdentity(String currentUid, String previousUid, Instant refreshFrom) { + this.currentUid = currentUid; + this.previousUid = previousUid; + this.refreshFrom = refreshFrom; + } + + public MappedIdentity(ApiIdentity apiIdentity) { + this(apiIdentity.currentUid, apiIdentity.previousUid, Instant.ofEpochSecond(apiIdentity.refreshFromSeconds)); + } + + private final String currentUid; + private final String previousUid; + private final Instant refreshFrom; + + public String getCurrentRawUid() { + return currentUid; + } + + public String getPreviousRawUid() { + return previousUid; + } + + public Instant getRefreshFrom() { + return refreshFrom; + } + } + + public static class UnmappedIdentity { + public UnmappedIdentity(String reason) + { + this.reason = UnmappedIdentityReason.fromString(reason); + this.rawReason = reason; + } + + public UnmappedIdentityReason getReason() { + return reason; + } + + public String getRawReason() { + return rawReason; + } + + private final UnmappedIdentityReason reason; + + private final String rawReason; + } + + public HashMap getMappedIdentities() { + return new HashMap<>(mappedIdentities); + } + + public HashMap getUnmappedIdentities() { + return new HashMap<>(unmappedIdentities); + } + + private final String status; + private final HashMap mappedIdentities = new HashMap<>(); + private final HashMap unmappedIdentities = new HashMap<>(); +} diff --git a/src/main/java/com/uid2/client/PublisherUid2Client.java b/src/main/java/com/uid2/client/PublisherUid2Client.java index 176347a..2261d99 100644 --- a/src/main/java/com/uid2/client/PublisherUid2Client.java +++ b/src/main/java/com/uid2/client/PublisherUid2Client.java @@ -21,7 +21,7 @@ public PublisherUid2Client(String uid2BaseUrl, String clientApiKey, String base6 public IdentityTokens generateToken(TokenGenerateInput tokenGenerateInput) { EnvelopeV2 envelope = publisherUid2Helper.createEnvelopeForTokenGenerateRequest(tokenGenerateInput); - String responseString = uid2ClientHelper.makeRequest(envelope, "/v2/token/generate"); + String responseString = uid2ClientHelper.makeRequest("/v2/token/generate", envelope).getAsString(); return publisherUid2Helper.createIdentityfromTokenGenerateResponse(responseString, envelope); } @@ -33,7 +33,7 @@ public IdentityTokens generateToken(TokenGenerateInput tokenGenerateInput) { public TokenGenerateResponse generateTokenResponse(TokenGenerateInput tokenGenerateInput) { EnvelopeV2 envelope = publisherUid2Helper.createEnvelopeForTokenGenerateRequest(tokenGenerateInput); - String responseString = uid2ClientHelper.makeRequest(envelope, "/v2/token/generate"); + String responseString = uid2ClientHelper.makeRequest("/v2/token/generate", envelope).getAsString(); return publisherUid2Helper.createTokenGenerateResponse(responseString, envelope); } @@ -42,7 +42,7 @@ public TokenGenerateResponse generateTokenResponse(TokenGenerateInput tokenGener * @return the refreshed IdentityTokens instance (with a new advertising token and updated expiry times). Typically, this will be used to replace the current identity in the user's session */ public TokenRefreshResponse refreshToken(IdentityTokens currentIdentity) { - String responseString = uid2ClientHelper.makeRequest(currentIdentity.getRefreshToken(), "/v2/token/refresh"); + String responseString = uid2ClientHelper.makeRequest("/v2/token/refresh", currentIdentity.getRefreshToken()).getAsString(); return PublisherUid2Helper.createTokenRefreshResponse(responseString, currentIdentity); } diff --git a/src/main/java/com/uid2/client/TokenHelper.java b/src/main/java/com/uid2/client/TokenHelper.java index 1e55734..ece7d3d 100644 --- a/src/main/java/com/uid2/client/TokenHelper.java +++ b/src/main/java/com/uid2/client/TokenHelper.java @@ -48,7 +48,7 @@ EncryptionDataResponse encryptRawUidIntoToken(String rawUid, Instant now) { RefreshResponse refresh(String urlSuffix) { try{ EnvelopeV2 envelope = uid2Helper.createEnvelopeV2("".getBytes()); - String responseString = uid2ClientHelper.makeRequest(envelope, urlSuffix); + String responseString = uid2ClientHelper.makeRequest(urlSuffix, envelope).getAsString(); byte[] response = uid2Helper.decrypt(responseString, envelope.getNonce()).getBytes(); this.container.set(KeyParser.parse(new ByteArrayInputStream(response))); return RefreshResponse.makeSuccess(); diff --git a/src/main/java/com/uid2/client/Uid2ClientHelper.java b/src/main/java/com/uid2/client/Uid2ClientHelper.java index f29bef2..85925c0 100644 --- a/src/main/java/com/uid2/client/Uid2ClientHelper.java +++ b/src/main/java/com/uid2/client/Uid2ClientHelper.java @@ -3,6 +3,7 @@ import okhttp3.*; import java.io.IOException; +import java.util.Objects; public class Uid2ClientHelper { Uid2ClientHelper(String baseUrl, String clientApiKey) { @@ -17,51 +18,73 @@ static Headers getHeaders(String clientApiKey) { .build(); } - String makeRequest(EnvelopeV2 envelope, String urlSuffix) { - return makeRequest(envelope.getEnvelope(), urlSuffix); + Uid2Response makeRequest(String urlSuffix, EnvelopeV2 envelope) { + return makeRequest(urlSuffix, getEnvelope(envelope)); } - String makeRequest(String requestBody, String urlSuffix) { + Uid2Response makeRequest(String urlSuffix, String payload) { + return makeRequest(RequestBody.create(payload, FORM), urlSuffix); + } + + private static String getEnvelope(EnvelopeV2 envelope) { + return envelope.getEnvelope(); + } + + Uid2Response makeBinaryRequest(String urlSuffix, EnvelopeV2 envelope) { + return makeRequest(RequestBody.create(envelope.getBinaryEnvelope(), BINARY), urlSuffix); + } + + Uid2Response makeRequest(RequestBody body, String urlSuffix) { Request request = new Request.Builder() .url(baseUrl + urlSuffix) .headers(headers) - .post(RequestBody.create(requestBody, FORM)) + .post(body) .build(); - try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful()) { throw new Uid2Exception("Unexpected code " + response); } - return getResponse(response); } catch (IOException e) { throw new Uid2Exception("error communicating with api endpoint", e); } } - private static String getResponse(Response response) { - String responseString; + private static Uid2Response getResponse(Response response) { + Uid2Response uid2Response; try { if (response == null) { throw new Uid2Exception("Response is null"); } else { - responseString = response.body() != null ? response.body().string() : response.toString(); + if (responseIsBinary(response)) { + byte[] bytes = response.body() != null ? response.body().bytes() : new byte[0]; + uid2Response = Uid2Response.fromBytes(bytes); + } else { + String string = response.body() != null ? response.body().string() : response.toString(); + uid2Response = Uid2Response.fromString(string); + } + if (!response.isSuccessful()) { - throw new Uid2Exception("Unexpected code " + responseString); + throw new Uid2Exception("Unexpected code " + response); } } - return responseString; + return uid2Response; } catch (IOException e) { throw new Uid2Exception("Error communicating with api endpoint", e); } } + private static boolean responseIsBinary(Response response) { + return Objects.equals(response.headers().get("Content-Type"), "application/octet-stream"); + } + private final OkHttpClient client = new OkHttpClient(); private final String baseUrl; private final Headers headers; private final static MediaType FORM = MediaType.get("application/x-www-form-urlencoded"); + private final static MediaType BINARY = MediaType.get("application/octet-stream"); } diff --git a/src/main/java/com/uid2/client/Uid2Helper.java b/src/main/java/com/uid2/client/Uid2Helper.java index a87e5c3..a8a722a 100644 --- a/src/main/java/com/uid2/client/Uid2Helper.java +++ b/src/main/java/com/uid2/client/Uid2Helper.java @@ -1,5 +1,7 @@ package com.uid2.client; +import org.jetbrains.annotations.NotNull; + import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -30,13 +32,17 @@ public EnvelopeV2 createEnvelopeV2(byte[] nonce, Instant timestamp, byte[] iv, b final byte envelopeVersion = 1; envelopeBuffer.put(envelopeVersion); envelopeBuffer.put(encrypted); - return new EnvelopeV2(InputUtil.byteArrayToBase64(envelopeBuffer.array()), nonce); + return new EnvelopeV2(envelopeBuffer.array(), nonce); } public String decrypt(String response, byte[] nonceInRequest) { return decrypt(response, secretKey, false, nonceInRequest); } + public String decrypt(byte[] response, byte[] nonceInRequest) { + return decrypt(response, secretKey, false, nonceInRequest); + } + static String decryptTokenRefreshResponse(String response, byte[] secretKey) { return decrypt(response, secretKey, true, null); } @@ -44,6 +50,10 @@ static String decryptTokenRefreshResponse(String response, byte[] secretKey) { private static String decrypt(String response, byte[] secretKey, boolean isRefreshResponse, byte[] nonceInRequest) { //from parseV2Response byte[] responseBytes = InputUtil.base64ToByteArray(response); + return decrypt(responseBytes, secretKey, isRefreshResponse, nonceInRequest); + } + + private static String decrypt(byte[] responseBytes, byte[] secretKey, boolean isRefreshResponse, byte[] nonceInRequest) { byte[] payload = Uid2Encryption.decryptGCM(responseBytes, 0, secretKey); byte[] resultBytes; diff --git a/src/main/java/com/uid2/client/Uid2Response.java b/src/main/java/com/uid2/client/Uid2Response.java new file mode 100644 index 0000000..17b25d4 --- /dev/null +++ b/src/main/java/com/uid2/client/Uid2Response.java @@ -0,0 +1,31 @@ +package com.uid2.client; + +public class Uid2Response { + String asString; + byte[] asBytes; + + private Uid2Response(String asString, byte[] asBytes) { + this.asString = asString; + this.asBytes = asBytes; + } + + public static Uid2Response fromString(String asString) { + return new Uid2Response(asString, null); + } + + public static Uid2Response fromBytes(byte[] asBytes) { + return new Uid2Response(null, asBytes); + } + + public String getAsString() { + return asString; + } + + public byte[] getAsBytes() { + return asBytes; + } + + public boolean isBinary() { + return asBytes != null; + } +} diff --git a/src/main/java/com/uid2/client/UnmappedIdentityReason.java b/src/main/java/com/uid2/client/UnmappedIdentityReason.java new file mode 100644 index 0000000..7c59ad1 --- /dev/null +++ b/src/main/java/com/uid2/client/UnmappedIdentityReason.java @@ -0,0 +1,19 @@ +package com.uid2.client; + + +public enum UnmappedIdentityReason { + OPTOUT, + INVALID_IDENTIFIER, + UNKNOWN; + + public static UnmappedIdentityReason fromString(String reason) { + if (reason.equals("optout")) { + return OPTOUT; + } + if (reason.equals("invalid identifier")) { + return INVALID_IDENTIFIER; + } + + return UNKNOWN; + } +} \ No newline at end of file diff --git a/src/test/java/com/uid2/client/IdentityMapV3IntegrationTests.java b/src/test/java/com/uid2/client/IdentityMapV3IntegrationTests.java new file mode 100644 index 0000000..17b8a51 --- /dev/null +++ b/src/test/java/com/uid2/client/IdentityMapV3IntegrationTests.java @@ -0,0 +1,286 @@ +package com.uid2.client; + +import okhttp3.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.*; + + +//most tests in this class require these env vars to be configured: UID2_BASE_URL, UID2_API_KEY, UID2_SECRET_KEY +@EnabledIfEnvironmentVariable(named = "UID2_BASE_URL", matches = "\\S+") +public class IdentityMapV3IntegrationTests { + final private IdentityMapV3Client identityMapClient = new IdentityMapV3Client(System.getenv("UID2_BASE_URL"), System.getenv("UID2_API_KEY"), System.getenv("UID2_SECRET_KEY")); + + String mappedPhone = "+98765432109"; + String mappedPhone2 = "+12345678901"; + String optedOutPhone = "+00000000000"; + String mappedPhoneHash = InputUtil.getBase64EncodedHash(mappedPhone2); + String optedOutPhoneHash = InputUtil.getBase64EncodedHash(optedOutPhone); + + String mappedEmail = "hopefully-not-opted-out@example.com"; + String optedOutEmail = "optout@example.com"; + String optedOutEmail2 = "somethingelse@example.com"; + String mappedEmailHash = InputUtil.normalizeAndHashEmail("mapped-email@example.com"); + String optedOutEmailHash = InputUtil.normalizeAndHashEmail(optedOutEmail); + + @Test + public void identityMapEmails() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromEmails(Arrays.asList(mappedEmail, optedOutEmail2, optedOutEmail)); + Response response = new Response(identityMapInput); + + response.assertMapped(mappedEmail); + response.assertMapped(optedOutEmail2); + + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, optedOutEmail); + } + + @Test + public void identityMapNothingUnmapped() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromEmails(Arrays.asList(mappedEmail, optedOutEmail2)); + Response response = new Response(identityMapInput); + + response.assertMapped(mappedEmail); + response.assertMapped(optedOutEmail2); + } + + @Test + public void identityMapNothingMapped() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromEmails(Collections.singletonList(optedOutEmail)); + Response response = new Response(identityMapInput); + + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, optedOutEmail); + } + + + @Test + public void identityMapInvalidEmail() { + assertThrows(IllegalArgumentException.class, + () -> IdentityMapV3Input.fromEmails(Arrays.asList(mappedEmail, "this is not an email"))); + } + + @Test + public void identityMapInvalidPhone() { + assertThrows(IllegalArgumentException.class, + () -> IdentityMapV3Input.fromPhones(Arrays.asList(mappedPhone, "this is not a phone number"))); + } + + @Test + public void identityMapInvalidHashedEmail() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromHashedEmails(Collections.singletonList("this is not a hashed email")); + + Response response = new Response(identityMapInput); + + response.assertUnmapped(UnmappedIdentityReason.INVALID_IDENTIFIER, "this is not a hashed email"); + } + + @Test + public void identityMapInvalidHashedPhone() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromHashedPhones(Collections.singletonList("this is not a hashed phone")); + + Response response = new Response(identityMapInput); + response.assertUnmapped(UnmappedIdentityReason.INVALID_IDENTIFIER, "this is not a hashed phone"); + } + + @Test + public void identityMapHashedEmails() { + String hashedEmail1 = InputUtil.normalizeAndHashEmail(mappedEmail); + String hashedEmail2 = mappedEmailHash; + String hashedOptedOutEmail = optedOutEmailHash; + + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromHashedEmails(Arrays.asList(hashedEmail1, hashedEmail2, hashedOptedOutEmail)); + Response response = new Response(identityMapInput); + + response.assertMapped(hashedEmail1); + response.assertMapped(hashedEmail2); + + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, hashedOptedOutEmail); + } + + @Test + public void identityMapDuplicateEmails() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromEmails(Arrays.asList("JANE.SAOIRSE@gmail.com", "Jane.Saoirse@gmail.com", "JaneSaoirse+UID2@gmail.com", "janesaoirse@gmail.com", "JANE.SAOIRSE@gmail.com")); + IdentityMapV3Response identityMapResponse = identityMapClient.generateIdentityMap(identityMapInput); + + HashMap mappedIdentities = identityMapResponse.getMappedIdentities(); + assertEquals(4, mappedIdentities.size()); //it's not 5 because the last email is an exact match to the first email + + String rawUid = mappedIdentities.get("JANE.SAOIRSE@gmail.com").getCurrentRawUid(); + assertEquals(rawUid, mappedIdentities.get("Jane.Saoirse@gmail.com").getCurrentRawUid()); + assertEquals(rawUid, mappedIdentities.get("JaneSaoirse+UID2@gmail.com").getCurrentRawUid()); + assertEquals(rawUid, mappedIdentities.get("janesaoirse@gmail.com").getCurrentRawUid()); + } + + + @Test + public void identityMapDuplicateHashedEmails() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromHashedEmails(Arrays.asList(mappedEmailHash, mappedEmailHash, optedOutEmailHash, optedOutEmailHash)); + Response response = new Response(identityMapInput); + + response.assertMapped(mappedEmailHash); + + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, optedOutEmailHash); + } + + @Test + public void identityMapEmptyInput() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromEmails(Collections.emptyList()); + IdentityMapV3Response identityMapResponse = identityMapClient.generateIdentityMap(identityMapInput); + assertTrue(identityMapResponse.getMappedIdentities().isEmpty()); + assertTrue(identityMapResponse.getUnmappedIdentities().isEmpty()); + } + + + @Test + public void identityMapPhones() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromPhones(Arrays.asList(mappedPhone, mappedPhone2, optedOutPhone)); + Response response = new Response(identityMapInput); + + response.assertMapped(mappedPhone); + response.assertMapped(mappedPhone2); + + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, optedOutPhone); + } + + @Test + public void identityMapHashedPhones() { + String hashedPhone1 = mappedPhoneHash; + String hashedPhone2 = InputUtil.getBase64EncodedHash(mappedPhone); + String hashedOptedOutPhone = optedOutPhoneHash; + + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromHashedPhones(Arrays.asList(hashedPhone1, hashedPhone2, hashedOptedOutPhone)); + Response response = new Response(identityMapInput); + + response.assertMapped(hashedPhone1); + response.assertMapped(hashedPhone2); + + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, hashedOptedOutPhone); + } + + @Test + public void identityMapAllIdentityTypesInOneRequest() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input + .fromEmails(Arrays.asList(mappedEmail, optedOutEmail)) + .withHashedEmails(Arrays.asList(mappedEmailHash, optedOutEmailHash)) + .withPhones(Arrays.asList(mappedPhone, optedOutPhone)) + .withHashedPhones(Arrays.asList(mappedPhoneHash, optedOutPhoneHash)); + + Response response = new Response(identityMapInput); + + response.assertMapped(mappedEmail); + response.assertMapped(mappedEmailHash); + response.assertMapped(mappedPhone); + response.assertMapped(mappedPhoneHash); + + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, optedOutEmail); + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, optedOutEmailHash); + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, optedOutPhone); + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, optedOutPhoneHash); + } + + @Test + public void identityMapAllIdentityTypesInOneRequestAddedOneByOne() { + IdentityMapV3Input identityMapInput = new IdentityMapV3Input(); + + identityMapInput.withEmail(mappedEmail); + identityMapInput.withPhone(optedOutPhone); + identityMapInput.withHashedPhone(mappedPhoneHash); + identityMapInput.withHashedEmail(optedOutEmailHash); + + Response response = new Response(identityMapInput); + + response.assertMapped(mappedEmail); + response.assertMapped(mappedPhoneHash); + + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, optedOutPhone); + response.assertUnmapped(UnmappedIdentityReason.OPTOUT, optedOutEmailHash); + } + + + class Response { + Response(IdentityMapV3Input identityMapInput) { + identityMapResponse = identityMapClient.generateIdentityMap(identityMapInput); + } + + void assertMapped(String dii) { + IdentityMapV3Response.MappedIdentity mappedIdentity = identityMapResponse.getMappedIdentities().get(dii); + assertNotNull(mappedIdentity); + assertFalse(mappedIdentity.getCurrentRawUid().isEmpty()); + + // Refresh from should be now or in the future, allow some slack for time between request and this assertion + Instant aMinuteAgo = Instant.now().minusSeconds(60); + assertTrue(mappedIdentity.getRefreshFrom().isAfter(aMinuteAgo)); + + IdentityMapV3Response.UnmappedIdentity unmappedIdentityReason = identityMapResponse.getUnmappedIdentities().get(dii); + assertNull(unmappedIdentityReason); + } + + void assertUnmapped(UnmappedIdentityReason reason, String dii) { + HashMap unmappedIdentities = identityMapResponse.getUnmappedIdentities(); + IdentityMapV3Response.UnmappedIdentity unmappedIdentity = unmappedIdentities.get(dii); + assertEquals(reason, unmappedIdentity.getReason()); + + IdentityMapV3Response.MappedIdentity mappedIdentity = identityMapResponse.getMappedIdentities().get(dii); + assertNull(mappedIdentity); + } + + + private final IdentityMapV3Response identityMapResponse; + } + + @Test + public void identityMapEmailsUseOwnHttp() { + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromEmails(Arrays.asList(mappedEmail, optedOutEmail2, optedOutEmail)); + + final IdentityMapV3Helper identityMapHelper = new IdentityMapV3Helper(System.getenv("UID2_SECRET_KEY")); + EnvelopeV2 envelopeV2 = identityMapHelper.createEnvelopeForIdentityMapRequest(identityMapInput); + String uid2BaseUrl = System.getenv("UID2_BASE_URL"); + String clientApiKey = System.getenv("UID2_API_KEY"); + Headers headers = new Headers.Builder().add("Authorization", "Bearer " + clientApiKey).add("X-UID2-Client-Version: java-" + Uid2Helper.getArtifactAndVersion()).build(); + + Request request = new Request.Builder().url(uid2BaseUrl + "/v3/identity/map").headers(headers) + .post(RequestBody.create(envelopeV2.getEnvelope(), MediaType.get("application/x-www-form-urlencoded"))).build(); + + OkHttpClient client = new OkHttpClient(); + try (okhttp3.Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new Uid2Exception("Unexpected code " + response); + } + + String responseString = response.body() != null ? response.body().string() : response.toString(); + IdentityMapV3Response identityMapResponse = identityMapHelper.createIdentityMapResponse(responseString, envelopeV2, identityMapInput); + + IdentityMapV3Response.MappedIdentity mappedIdentity = identityMapResponse.getMappedIdentities().get(mappedEmail); + assertFalse(mappedIdentity.getCurrentRawUid().isEmpty()); + } catch (IOException e) { + throw new Uid2Exception("error communicating with api endpoint", e); + } + } + + @Test + public void identityMapBadUrl() { + IdentityMapV3Client identityMapClient = new IdentityMapV3Client("https://operator-bad-url.uidapi.com", System.getenv("UID2_API_KEY"), System.getenv("UID2_SECRET_KEY")); + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromEmails(Collections.singletonList("email@example.com")); + assertThrows(Uid2Exception.class, () -> identityMapClient.generateIdentityMap(identityMapInput)); + } + + @Test + public void identityMapBadApiKey() { + IdentityMapV3Client identityMapClient = new IdentityMapV3Client(System.getenv("UID2_BASE_URL"), "bad-api-key", System.getenv("UID2_SECRET_KEY")); + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromEmails(Collections.singletonList("email@example.com")); + assertThrows(Uid2Exception.class, () -> identityMapClient.generateIdentityMap(identityMapInput)); + } + + @Test + public void identityMapBadSecret() { + IdentityMapV3Client identityMapClient = new IdentityMapV3Client(System.getenv("UID2_BASE_URL"), System.getenv("UID2_API_KEY"), "wJ0hP19QU4hmpB64Y3fV2dAed8t/mupw3sjN5jNRFzg="); + IdentityMapV3Input identityMapInput = IdentityMapV3Input.fromEmails(Collections.singletonList("email@example.com")); + assertThrows(Uid2Exception.class, () -> identityMapClient.generateIdentityMap(identityMapInput)); + } +} diff --git a/src/test/java/com/uid2/client/IdentityMapV3ResponseTest.java b/src/test/java/com/uid2/client/IdentityMapV3ResponseTest.java new file mode 100644 index 0000000..bba321c --- /dev/null +++ b/src/test/java/com/uid2/client/IdentityMapV3ResponseTest.java @@ -0,0 +1,170 @@ +package com.uid2.client; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +public class IdentityMapV3ResponseTest { + private final static String SOME_EMAIL = "email1@example.com"; + + @Test + void mappedIdentity() { + String email1 = "email1@example.com"; + String email2 = "email2@example.com"; + String phone1 = "+1234567890"; + String phone2 = "+0987654321"; + String hashedEmail1 = "email 1 hash"; + String hashedEmail2 = "email 2 hash"; + String hashedPhone1 = "phone 1 hash"; + String hashedPhone2 = "phone 2 hash"; + + Instant email1RefreshFrom = Instant.parse("2025-01-01T00:00:01Z"); + Instant email2RefreshFrom = Instant.parse("2025-06-30T00:00:20Z"); + Instant phone1RefreshFrom = Instant.parse("2025-01-01T00:05:00Z"); + Instant phone2RefreshFrom = Instant.parse("2025-06-30T00:00:22Z"); + Instant hashedEmail1RefreshFrom = Instant.parse("2025-01-01T00:00:33Z"); + Instant hashedEmail2RefreshFrom = Instant.parse("2025-06-30T00:00:00Z"); + Instant hashedPhone1RefreshFrom = Instant.parse("2025-01-01T00:00:11Z"); + Instant hashedPhone2RefreshFrom = Instant.parse("2025-06-30T00:00:01Z"); + + // Response from Operator + String[] emailHashEntries = { + mappedResponsePayloadEntry("email 1 current uid", "email 1 previous uid", email1RefreshFrom), + mappedResponsePayloadEntry("email 2 current uid", "email 2 previous uid", email2RefreshFrom), + mappedResponsePayloadEntry("hashed email 1 current uid", "hashed email 1 previous uid", hashedEmail1RefreshFrom), + mappedResponsePayloadEntry("hashed email 2 current uid", "hashed email 2 previous uid", hashedEmail2RefreshFrom) + }; + + String[] phoneHashEntries = { + mappedResponsePayloadEntry("phone 1 current uid", "phone 1 previous uid", phone1RefreshFrom), + mappedResponsePayloadEntry("phone 2 current uid", "phone 2 previous uid", phone2RefreshFrom), + mappedResponsePayloadEntry("hashed phone 1 current uid", "hashed phone 1 previous uid", hashedPhone1RefreshFrom), + mappedResponsePayloadEntry("hashed phone 2 current uid", "hashed phone 2 previous uid", hashedPhone2RefreshFrom) + }; + + String responsePayload = mappedResponsePayload(emailHashEntries, phoneHashEntries); + + IdentityMapV3Input input = new IdentityMapV3Input() + .withEmails(Arrays.asList(email1, email2)) + .withHashedEmails(Arrays.asList(hashedEmail1, hashedEmail2)) + .withPhones(Arrays.asList(phone1, phone2)) + .withHashedPhones(Arrays.asList(hashedPhone1, hashedPhone2)); + + IdentityMapV3Response response = new IdentityMapV3Response(responsePayload, input); + + assertTrue(response.isSuccess()); + assertEquals(8, response.getMappedIdentities().size()); + assertEquals(0, response.getUnmappedIdentities().size()); + + // Email + IdentityMapV3Response.MappedIdentity rawEmailMapping1 = response.getMappedIdentities().get(email1); + assertEquals("email 1 current uid", rawEmailMapping1.getCurrentRawUid()); + assertEquals("email 1 previous uid", rawEmailMapping1.getPreviousRawUid()); + assertEquals(email1RefreshFrom, rawEmailMapping1.getRefreshFrom()); + + IdentityMapV3Response.MappedIdentity rawEmailMapping2 = response.getMappedIdentities().get(email2); + assertEquals("email 2 current uid", rawEmailMapping2.getCurrentRawUid()); + assertEquals("email 2 previous uid", rawEmailMapping2.getPreviousRawUid()); + assertEquals(email2RefreshFrom, rawEmailMapping2.getRefreshFrom()); + + // Phone + IdentityMapV3Response.MappedIdentity rawPhoneMapping1 = response.getMappedIdentities().get(phone1); + assertEquals("phone 1 current uid", rawPhoneMapping1.getCurrentRawUid()); + assertEquals("phone 1 previous uid", rawPhoneMapping1.getPreviousRawUid()); + assertEquals(phone1RefreshFrom, rawPhoneMapping1.getRefreshFrom()); + + IdentityMapV3Response.MappedIdentity rawPhoneMapping2 = response.getMappedIdentities().get(phone2); + assertEquals("phone 2 current uid", rawPhoneMapping2.getCurrentRawUid()); + assertEquals("phone 2 previous uid", rawPhoneMapping2.getPreviousRawUid()); + assertEquals(phone2RefreshFrom, rawPhoneMapping2.getRefreshFrom()); + + // Hashed Email + IdentityMapV3Response.MappedIdentity hashedEmailMapping1 = response.getMappedIdentities().get(hashedEmail1); + assertEquals("hashed email 1 current uid", hashedEmailMapping1.getCurrentRawUid()); + assertEquals("hashed email 1 previous uid", hashedEmailMapping1.getPreviousRawUid()); + assertEquals(hashedEmail1RefreshFrom, hashedEmailMapping1.getRefreshFrom()); + + IdentityMapV3Response.MappedIdentity hashedEmailMapping2 = response.getMappedIdentities().get(hashedEmail2); + assertEquals("hashed email 2 current uid", hashedEmailMapping2.getCurrentRawUid()); + assertEquals("hashed email 2 previous uid", hashedEmailMapping2.getPreviousRawUid()); + assertEquals(hashedEmail2RefreshFrom, hashedEmailMapping2.getRefreshFrom()); + + // Hashed Phone + IdentityMapV3Response.MappedIdentity hashedPhoneMapping1 = response.getMappedIdentities().get(hashedPhone1); + assertEquals("hashed phone 1 current uid", hashedPhoneMapping1.getCurrentRawUid()); + assertEquals("hashed phone 1 previous uid", hashedPhoneMapping1.getPreviousRawUid()); + assertEquals(hashedPhone1RefreshFrom, hashedPhoneMapping1.getRefreshFrom()); + + IdentityMapV3Response.MappedIdentity hashedPhoneMapping2 = response.getMappedIdentities().get(hashedPhone2); + assertEquals("hashed phone 2 current uid", hashedPhoneMapping2.getCurrentRawUid()); + assertEquals("hashed phone 2 previous uid", hashedPhoneMapping2.getPreviousRawUid()); + assertEquals(hashedPhone2RefreshFrom, hashedPhoneMapping2.getRefreshFrom()); + } + + @Test + void unmappedIdentityReasonUnknown() { + IdentityMapV3Input input = IdentityMapV3Input.fromEmails(Arrays.asList(SOME_EMAIL)); + + IdentityMapV3Response response = new IdentityMapV3Response(unmappedResponsePayload("some new unmapped reason"), input); + assertTrue(response.isSuccess()); + + IdentityMapV3Response.UnmappedIdentity unmappedIdentity = response.getUnmappedIdentities().get(SOME_EMAIL); + assertEquals(UnmappedIdentityReason.UNKNOWN, unmappedIdentity.getReason()); + assertEquals("some new unmapped reason", unmappedIdentity.getRawReason()); + } + + @Test + void unmappedIdentityReasonOptout() { + IdentityMapV3Input input = IdentityMapV3Input.fromEmails(Arrays.asList(SOME_EMAIL)); + + IdentityMapV3Response response = new IdentityMapV3Response(unmappedResponsePayload("optout"), input); + assertTrue(response.isSuccess()); + + IdentityMapV3Response.UnmappedIdentity unmappedIdentity = response.getUnmappedIdentities().get(SOME_EMAIL); + assertEquals(UnmappedIdentityReason.OPTOUT, unmappedIdentity.getReason()); + assertEquals("optout", unmappedIdentity.getRawReason()); + } + + @Test + void unmappedIdentityReasonInvalid() { + IdentityMapV3Input input = IdentityMapV3Input.fromEmails(Arrays.asList(SOME_EMAIL)); + + IdentityMapV3Response response = new IdentityMapV3Response(unmappedResponsePayload("invalid identifier"), input); + assertTrue(response.isSuccess()); + + IdentityMapV3Response.UnmappedIdentity unmappedIdentity = response.getUnmappedIdentities().get(SOME_EMAIL); + assertEquals(UnmappedIdentityReason.INVALID_IDENTIFIER, unmappedIdentity.getReason()); + assertEquals("invalid identifier", unmappedIdentity.getRawReason()); + } + + @Test + void responseStatusNotSuccess() { + IdentityMapV3Input input = IdentityMapV3Input.fromEmails(Arrays.asList(SOME_EMAIL)); + + String failureResponsePayload = "{\"status\":\"error\",\"body\":{}}"; + + Uid2Exception exception = assertThrows(Uid2Exception.class, () -> { + new IdentityMapV3Response(failureResponsePayload, input); + }); + + assertEquals("Got unexpected identity map status: error", exception.getMessage()); + } + + private static String unmappedResponsePayload(String reason) { + return "{\"status\":\"success\",\"body\":{\"email_hash\":[{\"e\":\"" + reason + "\"}]}}"; + } + + private static String mappedResponsePayload(String[] emailHashEntries, String[] phoneHashEntries) { + return "{\"status\":\"success\",\"body\":{" + + "\"email_hash\":[" + String.join(",", emailHashEntries) + "]," + + "\"phone_hash\":[" + String.join(",", phoneHashEntries) + "]" + + "}}"; + } + + private static String mappedResponsePayloadEntry(String currentUid, String previousUid, Instant refreshFrom) { + return "{\"u\":\"" + currentUid + "\",\"p\":\"" + previousUid + "\",\"r\":" + refreshFrom.getEpochSecond() + "}"; + } +} \ No newline at end of file