Allow Protected Audience APIs to match subdomains

The URI validation in Protected Audience APIs currently requires a given
URI to exactly match an AdTechIdentifier.  Added more in-depth
validation to allow valid subdomains to be matched against hosts.

This change affects CustomAudience fields, AdSelectionConfig fields,
generated URIs from impression and event reporting, and fetched URIs
from the Custom Audience daily update.

Test: atest AdServicesServiceCoreUnitTests
Bug: 279446930
Change-Id: I9d82411fb9958f9062325d635c21fa2ce78f359e
diff --git a/adservices/service-core/java/com/android/adservices/service/common/AdTechUriValidator.java b/adservices/service-core/java/com/android/adservices/service/common/AdTechUriValidator.java
index 756a1b6..7264929 100644
--- a/adservices/service-core/java/com/android/adservices/service/common/AdTechUriValidator.java
+++ b/adservices/service-core/java/com/android/adservices/service/common/AdTechUriValidator.java
@@ -24,6 +24,7 @@
 
 import com.google.common.collect.ImmutableCollection;
 
+import java.util.Locale;
 import java.util.Objects;
 
 /**
@@ -83,16 +84,23 @@
         Objects.requireNonNull(violations);
 
         if (Objects.isNull(uri)) {
-            violations.add(String.format(URI_SHOULD_BE_SPECIFIED, mClassName, mUriFieldName));
+            violations.add(
+                    String.format(
+                            Locale.ENGLISH, URI_SHOULD_BE_SPECIFIED, mClassName, mUriFieldName));
         } else {
             String uriHost = uri.getHost();
             if (ValidatorUtil.isStringNullOrEmpty(uriHost)) {
                 violations.add(
-                        String.format(URI_SHOULD_HAVE_PRESENT_HOST, mClassName, mUriFieldName));
+                        String.format(
+                                Locale.ENGLISH,
+                                URI_SHOULD_HAVE_PRESENT_HOST,
+                                mClassName,
+                                mUriFieldName));
             } else if (!ValidatorUtil.isStringNullOrEmpty(mAdTechIdentifier)
-                    && !mAdTechIdentifier.equalsIgnoreCase(uriHost)) {
+                    && !matchesEtldPlus1(uriHost)) {
                 violations.add(
                         String.format(
+                                Locale.ENGLISH,
                                 IDENTIFIER_AND_URI_ARE_INCONSISTENT,
                                 mAdTechRole,
                                 mAdTechIdentifier,
@@ -104,4 +112,15 @@
             }
         }
     }
+
+    private boolean matchesEtldPlus1(@Nullable String uriHost) {
+        if (ValidatorUtil.isStringNullOrEmpty(uriHost)) {
+            return false;
+        }
+
+        // Positive match if exact match or valid suffix with separating '.' (all case-insensitive)
+        return mAdTechIdentifier.equalsIgnoreCase(uriHost)
+                || uriHost.toLowerCase(Locale.ENGLISH)
+                        .endsWith("." + mAdTechIdentifier.toLowerCase(Locale.ENGLISH));
+    }
 }
diff --git a/adservices/tests/unittest/service-core/src/com/android/adservices/service/common/AdTechUriValidatorTest.java b/adservices/tests/unittest/service-core/src/com/android/adservices/service/common/AdTechUriValidatorTest.java
index f3188de..3ec35cc 100644
--- a/adservices/tests/unittest/service-core/src/com/android/adservices/service/common/AdTechUriValidatorTest.java
+++ b/adservices/tests/unittest/service-core/src/com/android/adservices/service/common/AdTechUriValidatorTest.java
@@ -16,12 +16,16 @@
 
 package com.android.adservices.service.common;
 
+import static com.google.common.truth.Truth.assertWithMessage;
+
 import android.adservices.common.CommonFixture;
 import android.net.Uri;
 
 import org.junit.Assert;
 import org.junit.Test;
 
+import java.util.Locale;
+
 public class AdTechUriValidatorTest {
     private static final String CLASS_NAME = "class";
     private static final String URI_FIELD_NAME = "field";
@@ -34,7 +38,7 @@
                     URI_FIELD_NAME);
 
     @Test
-    public void testValidUri() {
+    public void testValidUri_hasNoViolation() {
         Assert.assertTrue(
                 mValidator
                         .getValidationViolations(
@@ -43,29 +47,34 @@
     }
 
     @Test
-    public void testNullUri() {
+    public void testNullUri_hasViolation() {
         ValidatorTestUtil.assertViolationContainsOnly(
                 mValidator.getValidationViolations(null),
                 String.format(
-                        AdTechUriValidator.URI_SHOULD_BE_SPECIFIED, CLASS_NAME, URI_FIELD_NAME));
+                        Locale.ENGLISH,
+                        AdTechUriValidator.URI_SHOULD_BE_SPECIFIED,
+                        CLASS_NAME,
+                        URI_FIELD_NAME));
     }
 
     @Test
-    public void testNoHostUri() {
+    public void testNoHostUri_hasViolation() {
         ValidatorTestUtil.assertViolationContainsOnly(
                 mValidator.getValidationViolations(Uri.parse("/a/b/c")),
                 String.format(
+                        Locale.ENGLISH,
                         AdTechUriValidator.URI_SHOULD_HAVE_PRESENT_HOST,
                         CLASS_NAME,
                         URI_FIELD_NAME));
     }
 
     @Test
-    public void testNotMatchHost() {
+    public void testNotMatchHost_hasViolation() {
         String uriHost = "buy.com";
         ValidatorTestUtil.assertViolationContainsOnly(
                 mValidator.getValidationViolations(Uri.parse("https://" + uriHost + "/not/match")),
                 String.format(
+                        Locale.ENGLISH,
                         AdTechUriValidator.IDENTIFIER_AND_URI_ARE_INCONSISTENT,
                         ValidatorUtil.AD_TECH_ROLE_BUYER,
                         CommonFixture.VALID_BUYER_1,
@@ -75,10 +84,95 @@
     }
 
     @Test
-    public void testNotHttpsHost() {
+    public void testNotHttpsHost_hasViolation() {
         ValidatorTestUtil.assertViolationContainsOnly(
                 mValidator.getValidationViolations(
                         Uri.parse("http://" + CommonFixture.VALID_BUYER_1 + "/not/https/")),
-                String.format(AdTechUriValidator.URI_SHOULD_USE_HTTPS, CLASS_NAME, URI_FIELD_NAME));
+                String.format(
+                        Locale.ENGLISH,
+                        AdTechUriValidator.URI_SHOULD_USE_HTTPS,
+                        CLASS_NAME,
+                        URI_FIELD_NAME));
+    }
+
+    @Test
+    public void testDomainContainingNotMatchingHost_hasNoViolations() {
+        // The domain given contains but does not match the expected URI
+        Uri mismatchingUri =
+                Uri.parse(
+                        "https://subdomain."
+                                + CommonFixture.VALID_BUYER_1
+                                + ".fake.net/path/to/resource");
+
+        assertWithMessage("List of validation errors")
+                .that(mValidator.getValidationViolations(mismatchingUri))
+                .containsExactly(
+                        String.format(
+                                Locale.ENGLISH,
+                                AdTechUriValidator.IDENTIFIER_AND_URI_ARE_INCONSISTENT,
+                                ValidatorUtil.AD_TECH_ROLE_BUYER,
+                                CommonFixture.VALID_BUYER_1,
+                                ValidatorUtil.AD_TECH_ROLE_BUYER,
+                                URI_FIELD_NAME,
+                                mismatchingUri.getHost()));
+    }
+
+    @Test
+    public void testSubdomainMatchingHost_hasNoViolations() {
+        Uri subdomainUri =
+                Uri.parse("https://subdomain." + CommonFixture.VALID_BUYER_1 + "/path/to/resource");
+
+        assertWithMessage("List of validation errors")
+                .that(mValidator.getValidationViolations(subdomainUri))
+                .isEmpty();
+    }
+
+    @Test
+    public void testLongerSubdomainMatchingHost_hasNoViolations() {
+        Uri subdomainUri =
+                Uri.parse(
+                        "https://s.u.b.d.o.m.a.i.n."
+                                + CommonFixture.VALID_BUYER_1
+                                + "/path/to/resource");
+
+        assertWithMessage("List of validation errors")
+                .that(mValidator.getValidationViolations(subdomainUri))
+                .isEmpty();
+    }
+
+    @Test
+    public void testNonSubdomainEndingWithSameSubstring_hasViolation() {
+        // Note NO separating `.` so that the host is different
+        Uri mismatchingUri =
+                Uri.parse(
+                        "https://notasubdomain"
+                                + CommonFixture.VALID_BUYER_1
+                                + "/path/to/resource");
+
+        assertWithMessage("List of validation errors")
+                .that(mValidator.getValidationViolations(mismatchingUri))
+                .containsExactly(
+                        String.format(
+                                Locale.ENGLISH,
+                                AdTechUriValidator.IDENTIFIER_AND_URI_ARE_INCONSISTENT,
+                                ValidatorUtil.AD_TECH_ROLE_BUYER,
+                                CommonFixture.VALID_BUYER_1,
+                                ValidatorUtil.AD_TECH_ROLE_BUYER,
+                                URI_FIELD_NAME,
+                                mismatchingUri.getHost()));
+    }
+
+    @Test
+    public void testSubdomainWithMixedCaseMatchingHost_hasNoViolations() {
+        String mixedCaseHost = "tEst.COm";
+        assertWithMessage("Mixed case host matches original host")
+                .that(mixedCaseHost.equalsIgnoreCase(CommonFixture.VALID_BUYER_1.toString()))
+                .isTrue();
+
+        Uri subdomainUri = Uri.parse("https://suBdoMAiN." + mixedCaseHost + "/path/to/resource");
+
+        assertWithMessage("List of validation errors")
+                .that(mValidator.getValidationViolations(subdomainUri))
+                .isEmpty();
     }
 }