From ee8031d9376adab52299c275c3bf11cc739cfb57 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 2 Jun 2026 12:59:55 -0700 Subject: [PATCH 01/24] Use consistent naming style for unit tests --- .../msal4j/{CacheFormatTests.java => CacheFormatTest.java} | 2 +- .../microsoft/aad/msal4j/{CacheTests.java => CacheTest.java} | 2 +- .../aad/msal4j/{DateTimeTests.java => DateTimeTest.java} | 2 +- .../{HelperAndUtilityTests.java => HelperAndUtilityTest.java} | 2 +- ...JsonCompatibilityTests.java => JsonCompatibilityTest.java} | 4 ++-- .../{ManagedIdentityTests.java => ManagedIdentityTest.java} | 2 +- ...tonGrantTest.java => MsalOauthAuthorizationGrantTest.java} | 2 +- .../aad/msal4j/{OnBehalfOfTests.java => OnBehalfOfTest.java} | 2 +- .../{ServerTelemetryTests.java => ServerTelemetryTest.java} | 2 +- .../aad/msal4j/{TelemetryTests.java => TelemetryTest.java} | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) rename msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/{CacheFormatTests.java => CacheFormatTest.java} (99%) rename msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/{CacheTests.java => CacheTest.java} (99%) rename msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/{DateTimeTests.java => DateTimeTest.java} (99%) rename msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/{HelperAndUtilityTests.java => HelperAndUtilityTest.java} (99%) rename msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/{JsonCompatibilityTests.java => JsonCompatibilityTest.java} (98%) rename msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/{ManagedIdentityTests.java => ManagedIdentityTest.java} (99%) rename msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/{MsalOauthAuthorizatonGrantTest.java => MsalOauthAuthorizationGrantTest.java} (95%) rename msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/{OnBehalfOfTests.java => OnBehalfOfTest.java} (99%) rename msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/{ServerTelemetryTests.java => ServerTelemetryTest.java} (99%) rename msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/{TelemetryTests.java => TelemetryTest.java} (99%) diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java index a30a39513..35d2dbf4a 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java @@ -30,7 +30,7 @@ @ExtendWith(MockitoExtension.class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class CacheFormatTests { +class CacheFormatTest { String TOKEN_RESPONSE = "/token_response.json"; String TOKEN_RESPONSE_ID_TOKEN = "/token_response_id_token.json"; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java index ca2cf4f2e..11f0d1c07 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java @@ -21,7 +21,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -class CacheTests { +class CacheTest { private TokenCache tokenCache; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTest.java index 4b3cce13e..7d682ba06 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTest.java @@ -13,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.*; -class DateTimeTests { +class DateTimeTest { @Test void parseUnixTimestampInSeconds() { diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java index 08ca2e399..180d0a146 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java @@ -14,7 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; -class HelperAndUtilityTests { +class HelperAndUtilityTest { @Test void StringHelper_serializeQueryParameters_ValidUrlQueryStrings() { diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTest.java similarity index 98% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTest.java index cc15ee94c..9c60f0aec 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTest.java @@ -12,14 +12,14 @@ //These tests were added to ensure the new usages of com.azure.json are functionally the same as the old usages of com.nimbusds packages. //Once we are confident in the new behavior these should no longer be necessary. -class JsonCompatibilityTests { +class JsonCompatibilityTest { //New style, using helper methods in JsonHelper that use com.azure.json private final Map newStyleParsedClaims = JsonHelper.parseJsonToMap(JsonHelper.getTokenPayloadClaims(TestHelper.ENCODED_JWT)); //Old style, using com.nimbusds methods private final Map oldStyleParsedClaims = JWTParser.parse(TestHelper.ENCODED_JWT).getJWTClaimsSet().getClaims(); - JsonCompatibilityTests() throws ParseException { + JsonCompatibilityTest() throws ParseException { } @Test diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTest.java index f5a8ccc00..d0197cb8f 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTest.java @@ -37,7 +37,7 @@ @ExtendWith(MockitoExtension.class) @TestInstance(TestInstance.Lifecycle.PER_METHOD) -class ManagedIdentityTests { +class ManagedIdentityTest { private static final String EXPECTED_SKU = "MSAL.Java"; private static final String TEST_CORRELATION_ID = "00000000-0000-0000-0000-000000000001"; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizatonGrantTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizationGrantTest.java similarity index 95% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizatonGrantTest.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizationGrantTest.java index a8d8f3dea..278df934c 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizatonGrantTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizationGrantTest.java @@ -15,7 +15,7 @@ import java.util.Map; @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class MsalOauthAuthorizatonGrantTest { +class MsalOauthAuthorizationGrantTest { @Test void testToParameters() { diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTest.java index 5aeb9f279..b48d0eb3f 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTest.java @@ -18,7 +18,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class OnBehalfOfTests { +class OnBehalfOfTest { @Test void OnBehalfOf_InternalCacheLookup_Success() throws Exception { diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTest.java index 8f809962f..f9d7a394b 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTest.java @@ -20,7 +20,7 @@ import java.util.concurrent.Executors; @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class ServerTelemetryTests { +class ServerTelemetryTest { private static final String SCHEMA_VERSION = "5"; private static final String CURRENT_REQUEST_HEADER_NAME = "x-client-current-telemetry"; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java index 695816419..4cc256e4a 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java @@ -21,7 +21,7 @@ import java.util.function.Consumer; @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class TelemetryTests { +class TelemetryTest { private List> eventsReceived = new ArrayList<>(); private String tenantId = "tenantId123"; From 072f042cd1d2e2574f9f9688f5e17aed22db902b Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 2 Jun 2026 13:15:27 -0700 Subject: [PATCH 02/24] Add (very generous) code coverage gate to build --- msal4j-sdk/pom.xml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/msal4j-sdk/pom.xml b/msal4j-sdk/pom.xml index cd0af114a..404494486 100644 --- a/msal4j-sdk/pom.xml +++ b/msal4j-sdk/pom.xml @@ -329,6 +329,33 @@ report + + jacoco-check + verify + + check + + + false + + + BUNDLE + + + LINE + COVEREDRATIO + 0.65 + + + BRANCH + COVEREDRATIO + 0.50 + + + + + + From 3f2ca5f7310d933b63026068f4f81d725d1c4d29 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 2 Jun 2026 13:37:53 -0700 Subject: [PATCH 03/24] Better usage of helpers in integration tests --- .../aad/msal4j/AcquireTokenSilentIT.java | 57 ++++------- .../aad/msal4j/ApacheHttpClientAdapter.java | 4 +- .../aad/msal4j/AzureEnvironmentIT.java | 21 +--- .../aad/msal4j/CachePersistenceIT.java | 20 +--- .../microsoft/aad/msal4j/HttpClientIT.java | 13 +-- .../aad/msal4j/IntegrationTestHelper.java | 96 ++++++++++++++++++- .../microsoft/aad/msal4j/OnBehalfOfIT.java | 34 +++---- .../microsoft/aad/msal4j/RefreshTokenIT.java | 5 +- .../aad/msal4j/TestTokenCachePersistence.java | 27 ++++++ .../microsoft/aad/msal4j/TokenCacheIT.java | 20 +--- .../aad/msal4j/UsernamePasswordIT.java | 14 +-- .../pageobjects/B2CLocalLoginPage.java | 5 +- 12 files changed, 169 insertions(+), 147 deletions(-) create mode 100644 msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestTokenCachePersistence.java diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java index c2b95926f..69c1d34f8 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java @@ -29,7 +29,7 @@ void acquireTokenSilent_OrganizationAuthority_TokenRefreshed() throws Exception IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult result = acquireTokenSilently(pca, account, TestConstants.GRAPH_DEFAULT_SCOPE, false); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); } @Test @@ -47,7 +47,7 @@ void acquireTokenSilent_LabAuthority_TokenNotRefreshed() throws Exception { IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult acquireSilentResult = acquireTokenSilently(pca, account, TestConstants.GRAPH_DEFAULT_SCOPE, false); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); // Check that access and id tokens are coming from cache assertEquals(result.accessToken(), acquireSilentResult.accessToken()); @@ -67,14 +67,14 @@ void acquireTokenSilent_ForceRefresh() throws Exception { build(); IAuthenticationResult result = acquireTokenUsernamePassword(user, pca, TestConstants.GRAPH_DEFAULT_SCOPE); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult resultAfterRefresh = acquireTokenSilently(pca, account, TestConstants.GRAPH_DEFAULT_SCOPE, true); - assertResultNotNull(resultAfterRefresh); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(resultAfterRefresh); // Check that new refresh and id tokens are being returned - assertTokensAreNotEqual(result, resultAfterRefresh); + IntegrationTestHelper.assertTokensAreNotEqual(result, resultAfterRefresh); assertEquals(TokenSource.IDENTITY_PROVIDER, result.metadata().tokenSource()); assertEquals(TokenSource.IDENTITY_PROVIDER, resultAfterRefresh.metadata().tokenSource()); } @@ -154,11 +154,11 @@ void acquireTokenSilent_WithRefreshOn() throws Exception { build(); IAuthenticationResult resultOriginal = acquireTokenUsernamePassword(user, pca, TestConstants.GRAPH_DEFAULT_SCOPE); - assertResultNotNull(resultOriginal); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(resultOriginal); IAuthenticationResult resultSilent = acquireTokenSilently(pca, resultOriginal.account(), TestConstants.GRAPH_DEFAULT_SCOPE, false); assertNotNull(resultSilent); - assertTokensAreEqual(resultOriginal, resultSilent); + IntegrationTestHelper.assertTokensAreEqual(resultOriginal, resultSilent); //When this test was made, token responses did not contain the refresh_in field needed for an end-to-end test. //In order to test silent flow behavior as though the service returned refresh_in, we manually change a cached @@ -174,7 +174,7 @@ void acquireTokenSilent_WithRefreshOn() throws Exception { //Current time is before refreshOn, so token should not have been refreshed assertNotNull(resultSilentWithRefreshOn); assertEquals(pca.tokenCache.accessTokens.get(key).refreshOn(), Long.toString(currTimestampSec + 60)); - assertTokensAreEqual(resultSilent, resultSilentWithRefreshOn); + IntegrationTestHelper.assertTokensAreEqual(resultSilent, resultSilentWithRefreshOn); token = pca.tokenCache.accessTokens.get(key); token.refreshOn(Long.toString(currTimestampSec - 60)); @@ -183,7 +183,7 @@ void acquireTokenSilent_WithRefreshOn() throws Exception { resultSilentWithRefreshOn = acquireTokenSilently(pca, resultOriginal.account(), TestConstants.GRAPH_DEFAULT_SCOPE, false); //Current time is after refreshOn, so token should be refreshed assertNotNull(resultSilentWithRefreshOn); - assertTokensAreNotEqual(resultSilent, resultSilentWithRefreshOn); + IntegrationTestHelper.assertTokensAreNotEqual(resultSilent, resultSilentWithRefreshOn); assertEquals(TokenSource.CACHE, resultSilent.metadata().tokenSource()); assertEquals(TokenSource.IDENTITY_PROVIDER, resultSilentWithRefreshOn.metadata().tokenSource()); } @@ -203,19 +203,19 @@ void acquireTokenSilent_TenantAsParameter() throws Exception { user.getUpn(), user.getPassword().toCharArray()) .build()).get(); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult silentResult = acquireTokenSilently(pca, account, TestConstants.GRAPH_DEFAULT_SCOPE, false); - assertResultNotNull(silentResult); - assertTokensAreEqual(result, silentResult); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(silentResult); + IntegrationTestHelper.assertTokensAreEqual(result, silentResult); IAuthenticationResult resultWithTenantParam = pca.acquireTokenSilently(SilentParameters. builder(Collections.singleton(TestConstants.GRAPH_DEFAULT_SCOPE), account). tenant(user.getTenantId()). build()).get(); - assertResultNotNull(resultWithTenantParam); - assertTokensAreNotEqual(result, resultWithTenantParam); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(resultWithTenantParam); + IntegrationTestHelper.assertTokensAreNotEqual(result, resultWithTenantParam); } @Test @@ -230,11 +230,11 @@ void acquireTokenSilent_emptyStringScope() throws Exception { String emptyScope = StringHelper.EMPTY_STRING; IAuthenticationResult result = acquireTokenUsernamePassword(user, pca, emptyScope); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult silentResult = acquireTokenSilently(pca, account, emptyScope, false); - assertResultNotNull(silentResult); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(silentResult); assertEquals(result.accessToken(), silentResult.accessToken()); } @@ -255,7 +255,7 @@ void acquireTokenSilent_emptyScopeSet() throws Exception { user.getPassword().toCharArray()) .build()) .get(); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult silentResult = pca.acquireTokenSilently(SilentParameters. @@ -263,7 +263,7 @@ void acquireTokenSilent_emptyScopeSet() throws Exception { .build()) .get(); - assertResultNotNull(silentResult); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(silentResult); assertEquals(result.accessToken(), silentResult.accessToken()); } @@ -285,14 +285,14 @@ public void acquireTokenSilent_ClaimsForceRefresh() throws Exception { .build()) .get(); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAuthenticationResult silentResultWithoutClaims = pca.acquireTokenSilently(SilentParameters. builder(scopes, result.account()) .build()) .get(); - assertResultNotNull(silentResultWithoutClaims); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(silentResultWithoutClaims); assertEquals(result.accessToken(), silentResultWithoutClaims.accessToken()); //If claims are added to a silent request, it should trigger the refresh flow and return a new token @@ -305,7 +305,7 @@ public void acquireTokenSilent_ClaimsForceRefresh() throws Exception { .build()) .get(); - assertResultNotNull(silentResultWithClaims); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(silentResultWithClaims); assertNotEquals(result.accessToken(), silentResultWithClaims.accessToken()); } @@ -376,19 +376,4 @@ private IAuthenticationResult acquireTokenUsernamePassword(UserConfig user, IPub .get(); } - private void assertResultNotNull(IAuthenticationResult result) { - assertNotNull(result); - assertNotNull(result.accessToken()); - assertNotNull(result.idToken()); - } - - private void assertTokensAreNotEqual(IAuthenticationResult result, IAuthenticationResult secondResult) { - assertNotEquals(result.accessToken(), secondResult.accessToken()); - assertNotEquals(result.idToken(), secondResult.idToken()); - } - - private void assertTokensAreEqual(IAuthenticationResult result, IAuthenticationResult secondResult) { - assertEquals(result.accessToken(), secondResult.accessToken()); - assertEquals(result.idToken(), secondResult.idToken()); - } } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ApacheHttpClientAdapter.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ApacheHttpClientAdapter.java index 89d6a2440..545b86d11 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ApacheHttpClientAdapter.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ApacheHttpClientAdapter.java @@ -41,7 +41,7 @@ public IHttpResponse send(HttpRequest httpRequest) throws Exception { private HttpRequestBase buildApacheRequestFromMsalRequest(HttpRequest httpRequest) { if (httpRequest.httpMethod() == HttpMethod.GET) { - return builGetRequest(httpRequest); + return buildGetRequest(httpRequest); } else if (httpRequest.httpMethod() == HttpMethod.POST) { return buildPostRequest(httpRequest); } else { @@ -49,7 +49,7 @@ private HttpRequestBase buildApacheRequestFromMsalRequest(HttpRequest httpReques } } - private HttpGet builGetRequest(HttpRequest httpRequest) { + private HttpGet buildGetRequest(HttpRequest httpRequest) { HttpGet httpGet = new HttpGet(httpRequest.url().toString()); for (Map.Entry entry : httpRequest.headers().entrySet()) { diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AzureEnvironmentIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AzureEnvironmentIT.java index f3471f3ad..ede81e5e0 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AzureEnvironmentIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AzureEnvironmentIT.java @@ -8,9 +8,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import java.util.Collections; @TestInstance(TestInstance.Lifecycle.PER_CLASS) class AzureEnvironmentIT { @@ -19,21 +17,12 @@ void acquireTokenWithUsernamePassword_AzureGovernment() throws Exception { AppConfig app = LabResponseHelper.getAppConfig(APP_ARLINGTON); UserConfig user = LabResponseHelper.getUserConfig(USER_ARLINGTON); - PublicClientApplication pca = PublicClientApplication.builder( - app.getAppId()). - authority(app.getAuthority() + "organizations/"). - build(); + PublicClientApplication pca = IntegrationTestHelper.createPublicApp( + app.getAppId(), app.getAuthority() + "organizations/"); - IAuthenticationResult result = pca.acquireToken(UserNamePasswordParameters - .builder(Collections.singleton(TestConstants.USER_READ_SCOPE), - user.getUpn(), - user.getPassword().toCharArray()) - .build()) - .get(); - - assertNotNull(result); - assertNotNull(result.accessToken()); - assertNotNull(result.idToken()); + IAuthenticationResult result = IntegrationTestHelper.acquireTokenByRopc( + pca, user, TestConstants.USER_READ_SCOPE); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); assertEquals(user.getUpn(), result.account().username()); } } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/CachePersistenceIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/CachePersistenceIT.java index 15324afae..6bac7d9fe 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/CachePersistenceIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/CachePersistenceIT.java @@ -9,30 +9,12 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class CachePersistenceIT { - static class TokenPersistence implements ITokenCacheAccessAspect { - String data; - - TokenPersistence(String data) { - this.data = data; - } - - @Override - public void beforeCacheAccess(ITokenCacheAccessContext iTokenCacheAccessContext) { - iTokenCacheAccessContext.tokenCache().deserialize(data); - } - - @Override - public void afterCacheAccess(ITokenCacheAccessContext iTokenCacheAccessContext) { - data = iTokenCacheAccessContext.tokenCache().serialize(); - } - } - @Test void cacheDeserializationSerializationTest() { String dataToInitCache = TestHelper.readResource(this.getClass(), "/cache_data/serialized_cache.json"); dataToInitCache = dataToInitCache.replace("", TestHelper.ENCODED_JWT); - ITokenCacheAccessAspect persistenceAspect = new TokenPersistence(dataToInitCache); + ITokenCacheAccessAspect persistenceAspect = new TestTokenCachePersistence(dataToInitCache); PublicClientApplication app = PublicClientApplication.builder("my_client_id") .setTokenCacheAccessAspect(persistenceAspect).build(); diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/HttpClientIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/HttpClientIT.java index fd67946fe..674db9bd9 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/HttpClientIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/HttpClientIT.java @@ -12,7 +12,6 @@ import java.util.concurrent.ExecutionException; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -50,16 +49,10 @@ private void assertAcquireTokenCommon(UserConfig user, String appId, IHttpClient httpClient(httpClient). build(); - IAuthenticationResult result = pca.acquireToken(UserNamePasswordParameters. - builder(Collections.singleton(TestConstants.GRAPH_DEFAULT_SCOPE), - user.getUpn(), - user.getPassword().toCharArray()) - .build()) - .get(); + IAuthenticationResult result = IntegrationTestHelper.acquireTokenByRopc( + pca, user, TestConstants.GRAPH_DEFAULT_SCOPE); - assertNotNull(result); - assertNotNull(result.accessToken()); - assertNotNull(result.idToken()); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); assertEquals(user.getUpn(), result.account().username()); } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/IntegrationTestHelper.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/IntegrationTestHelper.java index 04e437d2d..19f02ce18 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/IntegrationTestHelper.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/IntegrationTestHelper.java @@ -3,26 +3,112 @@ package com.microsoft.aad.msal4j; +import com.microsoft.aad.msal4j.labapi.UserConfig; + import java.net.MalformedURLException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; class IntegrationTestHelper { - static PublicClientApplication createPublicApp(String appID, String authority) { + // --- Application builders --- + + static PublicClientApplication createPublicApp(String appId, String authority) { try { - return PublicClientApplication.builder( - appID). - authority(authority). - build(); + return PublicClientApplication.builder(appId) + .authority(authority) + .build(); } catch (MalformedURLException e) { throw new RuntimeException(e); } } + static ConfidentialClientApplication createCca(String clientId, IClientCredential credential, String authority) { + try { + return ConfidentialClientApplication.builder(clientId, credential) + .authority(authority) + .build(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + // --- Token acquisition shortcuts --- + + static IAuthenticationResult acquireTokenByRopc( + PublicClientApplication pca, UserConfig user, Set scopes) + throws ExecutionException, InterruptedException { + return pca.acquireToken(UserNamePasswordParameters + .builder(scopes, user.getUpn(), user.getPassword().toCharArray()) + .build()) + .get(); + } + + static IAuthenticationResult acquireTokenByRopc( + PublicClientApplication pca, UserConfig user, String... scopes) + throws ExecutionException, InterruptedException { + return acquireTokenByRopc(pca, user, toScopeSet(scopes)); + } + + static IAuthenticationResult acquireTokenSilently( + IPublicClientApplication pca, IAccount account, Set scopes) + throws ExecutionException, InterruptedException, MalformedURLException { + return pca.acquireTokenSilently(SilentParameters + .builder(scopes, account) + .build()) + .get(); + } + + static IAuthenticationResult acquireTokenSilently( + IPublicClientApplication pca, IAccount account, String... scopes) + throws ExecutionException, InterruptedException, MalformedURLException { + return acquireTokenSilently(pca, account, toScopeSet(scopes)); + } + + // --- Assertions --- + static void assertAccessAndIdTokensNotNull(IAuthenticationResult result) { assertNotNull(result); assertNotNull(result.accessToken()); assertNotNull(result.idToken()); } + + static void assertAccessTokenNotNull(IAuthenticationResult result) { + assertNotNull(result); + assertNotNull(result.accessToken()); + } + + static void assertAccessTokensEqual(IAuthenticationResult result1, IAuthenticationResult result2) { + assertEquals(result1.accessToken(), result2.accessToken()); + } + + static void assertAccessTokensNotEqual(IAuthenticationResult result1, IAuthenticationResult result2) { + assertNotEquals(result1.accessToken(), result2.accessToken()); + } + + static void assertTokensAreEqual(IAuthenticationResult result1, IAuthenticationResult result2) { + assertEquals(result1.accessToken(), result2.accessToken()); + assertEquals(result1.idToken(), result2.idToken()); + } + + static void assertTokensAreNotEqual(IAuthenticationResult result1, IAuthenticationResult result2) { + assertNotEquals(result1.accessToken(), result2.accessToken()); + assertNotEquals(result1.idToken(), result2.idToken()); + } + + // --- Utilities --- + + private static Set toScopeSet(String... scopes) { + if (scopes.length == 1) { + return Collections.singleton(scopes[0]); + } + return new HashSet<>(Arrays.asList(scopes)); + } } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/OnBehalfOfIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/OnBehalfOfIT.java index 597bf4863..15921b2e4 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/OnBehalfOfIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/OnBehalfOfIT.java @@ -8,9 +8,6 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import java.util.Collections; @@ -41,7 +38,7 @@ void acquireTokenWithOBO_Managed() throws Exception { new UserAssertion(accessToken)).build()). get(); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessTokenNotNull(result); } @Test @@ -62,7 +59,7 @@ void acquireTokenWithOBO_testCache() throws Exception { new UserAssertion(accessToken)).build()). get(); - assertResultNotNull(result1); + IntegrationTestHelper.assertAccessTokenNotNull(result1); // Same scope and userAssertion, should return cached tokens IAuthenticationResult result2 = @@ -71,7 +68,7 @@ void acquireTokenWithOBO_testCache() throws Exception { new UserAssertion(accessToken)).build()). get(); - assertEquals(result1.accessToken(), result2.accessToken()); + IntegrationTestHelper.assertAccessTokensEqual(result1, result2); // Scope 2, should return new token IAuthenticationResult result3 = @@ -80,8 +77,8 @@ void acquireTokenWithOBO_testCache() throws Exception { new UserAssertion(accessToken)).build()). get(); - assertResultNotNull(result3); - assertNotEquals(result2.accessToken(), result3.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result3); + IntegrationTestHelper.assertAccessTokensNotEqual(result2, result3); // Scope 2, should return cached token IAuthenticationResult result4 = @@ -90,7 +87,7 @@ void acquireTokenWithOBO_testCache() throws Exception { new UserAssertion(accessToken)).build()). get(); - assertEquals(result3.accessToken(), result4.accessToken()); + IntegrationTestHelper.assertAccessTokensEqual(result3, result4); // skipCache=true, should return new token IAuthenticationResult result5 = @@ -102,9 +99,9 @@ void acquireTokenWithOBO_testCache() throws Exception { .build()). get(); - assertResultNotNull(result5); - assertNotEquals(result5.accessToken(), result4.accessToken()); - assertNotEquals(result5.accessToken(), result2.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result5); + IntegrationTestHelper.assertAccessTokensNotEqual(result5, result4); + IntegrationTestHelper.assertAccessTokensNotEqual(result5, result2); String newAccessToken = this.getAccessToken(); @@ -117,15 +114,10 @@ void acquireTokenWithOBO_testCache() throws Exception { .build()). get(); - assertResultNotNull(result6); - assertNotEquals(result6.accessToken(), result5.accessToken()); - assertNotEquals(result6.accessToken(), result4.accessToken()); - assertNotEquals(result6.accessToken(), result2.accessToken()); - } - - private void assertResultNotNull(IAuthenticationResult result) { - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result6); + IntegrationTestHelper.assertAccessTokensNotEqual(result6, result5); + IntegrationTestHelper.assertAccessTokensNotEqual(result6, result4); + IntegrationTestHelper.assertAccessTokensNotEqual(result6, result2); } private String getAccessToken() throws Exception { diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/RefreshTokenIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/RefreshTokenIT.java index a0e14db64..a41132288 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/RefreshTokenIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/RefreshTokenIT.java @@ -7,7 +7,6 @@ import static com.microsoft.aad.msal4j.labapi.KeyVaultSecrets.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.Collections; @@ -47,9 +46,7 @@ void acquireTokenWithRefreshToken() throws Exception { .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); - assertNotNull(result.idToken()); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); } @Test diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestTokenCachePersistence.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestTokenCachePersistence.java new file mode 100644 index 000000000..f6955ea77 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestTokenCachePersistence.java @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Simple in-memory token cache persistence for integration tests. + * Implements ITokenCacheAccessAspect to serialize/deserialize cache data + * across application instances. + */ +class TestTokenCachePersistence implements ITokenCacheAccessAspect { + String data; + + TestTokenCachePersistence(String data) { + this.data = data; + } + + @Override + public void beforeCacheAccess(ITokenCacheAccessContext iTokenCacheAccessContext) { + iTokenCacheAccessContext.tokenCache().deserialize(data); + } + + @Override + public void afterCacheAccess(ITokenCacheAccessContext iTokenCacheAccessContext) { + data = iTokenCacheAccessContext.tokenCache().serialize(); + } +} diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java index 570c289a7..1cf065bd8 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java @@ -65,7 +65,7 @@ void twoAccountsInCache_SameUserDifferentTenants_RemoveAccountTest() throws Exce // check that cache is empty assertEquals(dataToInitCache, ""); - ITokenCacheAccessAspect persistenceAspect = new TokenPersistence(dataToInitCache); + ITokenCacheAccessAspect persistenceAspect = new TestTokenCachePersistence(dataToInitCache); // acquire tokens for home tenant, and serialize cache PublicClientApplication pca = PublicClientApplication.builder( @@ -112,22 +112,4 @@ void twoAccountsInCache_SameUserDifferentTenants_RemoveAccountTest() throws Exce this.getClass(), "/cache_data/remove-account-test-cache.json"); } - - private static class TokenPersistence implements ITokenCacheAccessAspect { - String data; - - TokenPersistence(String data) { - this.data = data; - } - - @Override - public void beforeCacheAccess(ITokenCacheAccessContext iTokenCacheAccessContext) { - iTokenCacheAccessContext.tokenCache().deserialize(data); - } - - @Override - public void afterCacheAccess(ITokenCacheAccessContext iTokenCacheAccessContext) { - data = iTokenCacheAccessContext.tokenCache().serialize(); - } - } } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/UsernamePasswordIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/UsernamePasswordIT.java index f2ee9121c..9b45dc41e 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/UsernamePasswordIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/UsernamePasswordIT.java @@ -59,17 +59,9 @@ void acquireTokenWithUsernamePassword_Ciam() throws Exception { private void assertAcquireTokenCommon(UserConfig user, String authority, String scope, String appId) throws Exception { - PublicClientApplication pca = PublicClientApplication.builder( - appId). - authority(authority). - build(); + PublicClientApplication pca = IntegrationTestHelper.createPublicApp(appId, authority); - IAuthenticationResult result = pca.acquireToken(UserNamePasswordParameters. - builder(Collections.singleton(scope), - user.getUpn(), - user.getPassword().toCharArray()) - .build()) - .get(); + IAuthenticationResult result = IntegrationTestHelper.acquireTokenByRopc(pca, user, scope); IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); assertEquals(user.getUpn(), result.account().username()); @@ -95,7 +87,6 @@ void acquireTokenWithUsernamePassword_B2C_CustomAuthority() throws Exception { IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); - SilentParameters.builder(Collections.singleton(TestConstants.B2C_READ_SCOPE), account); result = pca.acquireTokenSilently( SilentParameters.builder(Collections.singleton(TestConstants.B2C_READ_SCOPE), account) @@ -125,7 +116,6 @@ void acquireTokenWithUsernamePassword_B2C_LoginMicrosoftOnline() throws Exceptio IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); - SilentParameters.builder(Collections.singleton(TestConstants.B2C_READ_SCOPE), account); result = pca.acquireTokenSilently( SilentParameters.builder(Collections.singleton(TestConstants.B2C_READ_SCOPE), account) diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java index 98bbf1820..8b40c1388 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java @@ -3,7 +3,6 @@ package infrastructure.pageobjects; -import com.microsoft.aad.msal4j.TestConstants; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import org.openqa.selenium.support.ui.ExpectedConditions; @@ -56,9 +55,9 @@ public B2CLocalLoginPage clickLocalAccount() { * @return This page object for method chaining */ public B2CLocalLoginPage enterUsername(String username) { - LOG.info("Entering username: {}", TestConstants.B2C_UPN); + LOG.info("Entering username: {}", username); wait.until(ExpectedConditions.elementToBeClickable(USERNAME_INPUT)) - .sendKeys(TestConstants.B2C_UPN); + .sendKeys(username); return this; } From 91cc4a297b1975701614a506c0fe9bfb3022ce38 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 2 Jun 2026 13:40:28 -0700 Subject: [PATCH 04/24] Small unit test fixes --- .../aad/msal4j/AcquireTokenSilentlyTest.java | 14 +------------- .../com/microsoft/aad/msal4j/TelemetryTest.java | 5 ----- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java index f957a079a..aae1214d3 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java @@ -13,10 +13,6 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.mockito.Mockito.*; -import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.Paths; import java.util.Collections; import java.util.HashMap; import java.util.concurrent.CompletableFuture; @@ -28,7 +24,7 @@ class AcquireTokenSilentlyTest { Account basicAccount = new Account("home_account_id", "login.windows.net", "username", null); - String cache = readResource("/AAD_cache_data/full_cache.json"); + String cache = TestHelper.readResource(this.getClass(), "/AAD_cache_data/full_cache.json"); @Test void publicAppAcquireTokenSilently_emptyCache_MsalClientException() throws Throwable { @@ -209,12 +205,4 @@ private void assertRefreshedToken(IAuthenticationResult result, String expectedT assertEquals(expectedToken, result.accessToken()); assertEquals(expectedReason, result.metadata().cacheRefreshReason()); } - - String readResource(String resource) { - try { - return new String(Files.readAllBytes(Paths.get(getClass().getResource(resource).toURI()))); - } catch (IOException | URISyntaxException e) { - throw new RuntimeException(e); - } - } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java index 4cc256e4a..551bd0513 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java @@ -32,11 +32,6 @@ private class MyTelemetryConsumer { Consumer>> telemetryConsumer = (List> telemetryEvents) -> { eventsReceived.addAll(telemetryEvents); - System.out.println("Received " + telemetryEvents.size() + " events"); - telemetryEvents.forEach(event -> { - System.out.print("Event Name: " + event.get("event_name")); - event.entrySet().forEach(entry -> System.out.println(" " + entry)); - }); }; } From e2a214d43b34a4ac4986d78f24138892e19e8ad9 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 2 Jun 2026 14:02:56 -0700 Subject: [PATCH 05/24] Consistently use correct order for assertEquals calls --- .../microsoft/aad/msal4j/TokenCacheIT.java | 6 +- .../aad/msal4j/AadInstanceDiscoveryTest.java | 6 +- .../com/microsoft/aad/msal4j/AccountTest.java | 14 ++-- .../microsoft/aad/msal4j/AuthorityTest.java | 68 +++++++++---------- ...AuthorizationRequestUrlParametersTest.java | 44 ++++++------ .../aad/msal4j/HelperAndUtilityTest.java | 2 +- .../microsoft/aad/msal4j/HttpHeaderTest.java | 36 +++++----- .../microsoft/aad/msal4j/MexParserTest.java | 10 +-- .../microsoft/aad/msal4j/TelemetryTest.java | 36 +++++----- .../aad/msal4j/TokenRequestExecutorTest.java | 4 +- .../aad/msal4j/WSTrustRequestTest.java | 2 +- 11 files changed, 114 insertions(+), 114 deletions(-) diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java index 1cf065bd8..2295a43e4 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java @@ -29,7 +29,7 @@ void singleAccountInCache_RemoveAccountTest() throws Exception { build(); // Check that cache is empty - assertEquals(pca.getAccounts().join().size(), 0); + assertEquals(0, pca.getAccounts().join().size()); Map extraQueryParameters = new HashMap<>(); extraQueryParameters.put("test", "test"); @@ -43,12 +43,12 @@ void singleAccountInCache_RemoveAccountTest() throws Exception { .get(); // Check that cache contains one account - assertEquals(pca.getAccounts().join().size(), 1); + assertEquals(1, pca.getAccounts().join().size()); pca.removeAccount(pca.getAccounts().join().iterator().next()).join(); // Check that account has been removed - assertEquals(pca.getAccounts().join().size(), 0); + assertEquals(0, pca.getAccounts().join().size()); } @Test diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryTest.java index f28aec53d..12123d93b 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryTest.java @@ -138,9 +138,9 @@ void aadInstanceDiscoveryTest_AutoDetectRegion_NoRegionDetected() throws Excepti } void assertValidResponse(InstanceDiscoveryMetadataEntry entry) { - assertEquals(entry.preferredNetwork(), "login.microsoftonline.com"); - assertEquals(entry.preferredCache(), "login.windows.net"); - assertEquals(entry.aliases().size(), 4); + assertEquals("login.microsoftonline.com", entry.preferredNetwork()); + assertEquals("login.windows.net", entry.preferredCache()); + assertEquals(4, entry.aliases().size()); assertTrue(entry.aliases().contains("login.microsoftonline.com")); assertTrue(entry.aliases().contains("login.windows.net")); assertTrue(entry.aliases().contains("login.microsoft.com")); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java index 108078171..60b5f4347 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java @@ -91,22 +91,22 @@ ITokenCacheAccessAspect init(String data) { Set accounts = pca.getAccounts().join(); - assertEquals(accounts.size(), 1); + assertEquals(1, accounts.size()); IAccount account = accounts.iterator().next(); Map tenantProfiles = account.getTenantProfiles(); - assertEquals(tenantProfiles.size(), 2); + assertEquals(2, tenantProfiles.size()); assertTrue(tenantProfiles.containsKey(BLACK_FORESRT_TENANT)); assertTrue(tenantProfiles.containsKey(WW_TENTANT)); pca.removeAccount(account).join(); accounts = pca.getAccounts().join(); - assertEquals(accounts.size(), 0); + assertEquals(0, accounts.size()); - assertEquals(pca.tokenCache.accounts.size(), 0); - assertEquals(pca.tokenCache.idTokens.size(), 0); - assertEquals(pca.tokenCache.refreshTokens.size(), 0); - assertEquals(pca.tokenCache.accessTokens.size(), 0); + assertEquals(0, pca.tokenCache.accounts.size()); + assertEquals(0, pca.tokenCache.idTokens.size()); + assertEquals(0, pca.tokenCache.refreshTokens.size()); + assertEquals(0, pca.tokenCache.accessTokens.size()); } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorityTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorityTest.java index 326e933f6..3af3e7947 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorityTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorityTest.java @@ -19,31 +19,31 @@ class AuthorityTest { @Test void testDetectAuthorityType_AAD() throws Exception { URL url = new URL(TestConfiguration.AAD_TENANT_ENDPOINT); - assertEquals(Authority.detectAuthorityType(url), AuthorityType.AAD); + assertEquals(AuthorityType.AAD, Authority.detectAuthorityType(url)); } @Test void testDetectAuthorityType_ADFS() throws Exception { URL url = new URL(TestConfiguration.ADFS_TENANT_ENDPOINT); - assertEquals(Authority.detectAuthorityType(url), AuthorityType.ADFS); + assertEquals(AuthorityType.ADFS, Authority.detectAuthorityType(url)); } @Test void testDetectAuthorityType_B2C() throws Exception { URL url = new URL(TestConfiguration.B2C_AUTHORITY); - assertEquals(Authority.detectAuthorityType(url), AuthorityType.B2C); + assertEquals(AuthorityType.B2C, Authority.detectAuthorityType(url)); } @ParameterizedTest @MethodSource("com.microsoft.aad.msal4j.AuthorityTest#ciamAuthorities") void testDetectAuthorityType_CIAM(URL authority) throws Exception { - assertEquals(Authority.detectAuthorityType(authority), AuthorityType.CIAM); + assertEquals(AuthorityType.CIAM, Authority.detectAuthorityType(authority)); } @ParameterizedTest @MethodSource("com.microsoft.aad.msal4j.AuthorityTest#validCiamAuthoritiesAndTransformedAuthority") void testCiamAuthorityTransformation(URL authority, URL transformedAuthority) throws Exception { - assertEquals(CIAMAuthority.transformAuthority(authority), transformedAuthority); + assertEquals(transformedAuthority, CIAMAuthority.transformAuthority(authority)); } @Test @@ -100,35 +100,35 @@ void testValidateAuthorityEmptyPath() { void testConstructor_AADAuthority() throws MalformedURLException { final AADAuthority aa = new AADAuthority(new URL(TestConfiguration.AAD_TENANT_ENDPOINT)); assertNotNull(aa); - assertEquals(aa.authority(), - TestConfiguration.AAD_TENANT_ENDPOINT); - assertEquals(aa.host(), TestConfiguration.AAD_HOST_NAME); - assertEquals(aa.tokenEndpoint(), - TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token"); - assertEquals(aa.selfSignedJwtAudience(), - TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token"); - assertEquals(aa.tokenEndpoint(), - TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token"); - assertEquals(aa.authorityType(), AuthorityType.AAD); + assertEquals(TestConfiguration.AAD_TENANT_ENDPOINT, + aa.authority()); + assertEquals(TestConfiguration.AAD_HOST_NAME, aa.host()); + assertEquals(TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token", + aa.tokenEndpoint()); + assertEquals(TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token", + aa.selfSignedJwtAudience()); + assertEquals(TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token", + aa.tokenEndpoint()); + assertEquals(AuthorityType.AAD, aa.authorityType()); assertFalse(aa.isTenantless()); - assertEquals(aa.deviceCodeEndpoint(), - TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/devicecode"); + assertEquals(TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/devicecode", + aa.deviceCodeEndpoint()); } @Test void testConstructor_B2CAuthority() throws MalformedURLException { final B2CAuthority aa = new B2CAuthority(new URL(TestConfiguration.B2C_AUTHORITY)); assertNotNull(aa); - assertEquals(aa.authority(), - TestConfiguration.B2C_AUTHORITY + "/"); - assertEquals(aa.host(), TestConfiguration.B2C_HOST_NAME); - assertEquals(aa.selfSignedJwtAudience(), - TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY); - assertEquals(aa.tokenEndpoint(), - TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY); - assertEquals(aa.authorityType(), AuthorityType.B2C); - assertEquals(aa.tokenEndpoint(), - TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY); + assertEquals(TestConfiguration.B2C_AUTHORITY + "/", + aa.authority()); + assertEquals(TestConfiguration.B2C_HOST_NAME, aa.host()); + assertEquals(TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY, + aa.selfSignedJwtAudience()); + assertEquals(TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY, + aa.tokenEndpoint()); + assertEquals(AuthorityType.B2C, aa.authorityType()); + assertEquals(TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY, + aa.tokenEndpoint()); assertFalse(aa.isTenantless()); } @@ -136,15 +136,15 @@ void testConstructor_B2CAuthority() throws MalformedURLException { void testConstructor_ADFSAuthority() throws MalformedURLException { final ADFSAuthority a = new ADFSAuthority(new URL(TestConfiguration.ADFS_TENANT_ENDPOINT)); assertNotNull(a); - assertEquals(a.authority(), TestConfiguration.ADFS_TENANT_ENDPOINT); - assertEquals(a.host(), TestConfiguration.ADFS_HOST_NAME); - assertEquals(a.selfSignedJwtAudience(), - TestConfiguration.ADFS_TENANT_ENDPOINT + ADFSAuthority.TOKEN_ENDPOINT); + assertEquals(TestConfiguration.ADFS_TENANT_ENDPOINT, a.authority()); + assertEquals(TestConfiguration.ADFS_HOST_NAME, a.host()); + assertEquals(TestConfiguration.ADFS_TENANT_ENDPOINT + ADFSAuthority.TOKEN_ENDPOINT, + a.selfSignedJwtAudience()); - assertEquals(a.authorityType(), AuthorityType.ADFS); + assertEquals(AuthorityType.ADFS, a.authorityType()); - assertEquals(a.tokenEndpoint(), - TestConfiguration.ADFS_TENANT_ENDPOINT + ADFSAuthority.TOKEN_ENDPOINT); + assertEquals(TestConfiguration.ADFS_TENANT_ENDPOINT + ADFSAuthority.TOKEN_ENDPOINT, + a.tokenEndpoint()); assertFalse(a.isTenantless()); } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java index 034ffc0ec..a200a24a1 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java @@ -35,10 +35,10 @@ void testBuilder_onlyRequiredParameters() throws UnsupportedEncodingException { .extraQueryParameters(extraParameters) .build(); - assertEquals(parameters.responseMode(), ResponseMode.FORM_POST); - assertEquals(parameters.redirectUri(), redirectUri); - assertEquals(parameters.scopes().size(), 4); - assertEquals(parameters.extraQueryParameters.size(), 2); + assertEquals(ResponseMode.FORM_POST, parameters.responseMode()); + assertEquals(redirectUri, parameters.redirectUri()); + assertEquals(4, parameters.scopes().size()); + assertEquals(2, parameters.extraQueryParameters.size()); assertNull(parameters.loginHint()); assertNull(parameters.codeChallenge()); @@ -50,8 +50,8 @@ void testBuilder_onlyRequiredParameters() throws UnsupportedEncodingException { URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); - assertEquals(authorizationUrl.getHost(), "login.microsoftonline.com"); - assertEquals(authorizationUrl.getPath(), "/common/oauth2/v2.0/authorize"); + assertEquals("login.microsoftonline.com", authorizationUrl.getHost()); + assertEquals("/common/oauth2/v2.0/authorize", authorizationUrl.getPath()); Map queryParameters = new HashMap<>(); String query = authorizationUrl.getQuery(); @@ -64,12 +64,12 @@ void testBuilder_onlyRequiredParameters() throws UnsupportedEncodingException { URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); } - assertEquals(queryParameters.get("scope"), "openid profile offline_access scope"); - assertEquals(queryParameters.get("response_type"), "code"); - assertEquals(queryParameters.get("redirect_uri"), "http://localhost:8080"); - assertEquals(queryParameters.get("client_id"), "client_id"); - assertEquals(queryParameters.get("response_mode"), "form_post"); - assertEquals(queryParameters.get("id_token_hint"),"test"); + assertEquals("openid profile offline_access scope", queryParameters.get("scope")); + assertEquals("code", queryParameters.get("response_type")); + assertEquals("http://localhost:8080", queryParameters.get("redirect_uri")); + assertEquals("client_id", queryParameters.get("client_id")); + assertEquals("form_post", queryParameters.get("response_mode")); + assertEquals("test", queryParameters.get("id_token_hint")); } @Test @@ -110,9 +110,9 @@ void testBuilder_responseMode() throws UnsupportedEncodingException { .responseMode(ResponseMode.QUERY) // This should be overridden to FORM_POST .build(); - assertEquals(parameters.responseMode(), ResponseMode.FORM_POST); - assertEquals(parameters.redirectUri(), redirectUri); - assertEquals(parameters.scopes().size(), 4); + assertEquals(ResponseMode.FORM_POST, parameters.responseMode()); + assertEquals(redirectUri, parameters.redirectUri()); + assertEquals(4, parameters.scopes().size()); assertNull(parameters.loginHint()); assertNull(parameters.codeChallenge()); @@ -124,8 +124,8 @@ void testBuilder_responseMode() throws UnsupportedEncodingException { URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); - assertEquals(authorizationUrl.getHost(), "login.microsoftonline.com"); - assertEquals(authorizationUrl.getPath(), "/common/oauth2/v2.0/authorize"); + assertEquals("login.microsoftonline.com", authorizationUrl.getHost()); + assertEquals("/common/oauth2/v2.0/authorize", authorizationUrl.getPath()); Map queryParameters = new HashMap<>(); String query = authorizationUrl.getQuery(); @@ -138,10 +138,10 @@ void testBuilder_responseMode() throws UnsupportedEncodingException { URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); } - assertEquals(queryParameters.get("scope"), "openid profile offline_access scope"); - assertEquals(queryParameters.get("response_type"), "code"); - assertEquals(queryParameters.get("redirect_uri"), "http://localhost:8080"); - assertEquals(queryParameters.get("client_id"), "client_id"); - assertEquals(queryParameters.get("response_mode"), "form_post"); + assertEquals("openid profile offline_access scope", queryParameters.get("scope")); + assertEquals("code", queryParameters.get("response_type")); + assertEquals("http://localhost:8080", queryParameters.get("redirect_uri")); + assertEquals("client_id", queryParameters.get("client_id")); + assertEquals("form_post", queryParameters.get("response_mode")); } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java index 180d0a146..895570c85 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java @@ -78,7 +78,7 @@ void StringHelper_convertToMultiValueMap() { assertNotNull(convertedInternalMap, "Converted map should not be null"); assertNotNull(methodReturnedMap, "Method returned map should not be null"); - assertEquals(convertedInternalMap.size(), methodReturnedMap.size(), "Maps should have the same size"); + assertEquals(methodReturnedMap.size(), convertedInternalMap.size(), "Maps should have the same size"); for (String key : convertedInternalMap.keySet()) { assertTrue(methodReturnedMap.containsKey(key), "Method returned map should contain key: " + key); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HttpHeaderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HttpHeaderTest.java index 60ae060f7..3c5706596 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HttpHeaderTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HttpHeaderTest.java @@ -40,12 +40,12 @@ void testHttpHeaderConstructor() { Map httpHeaderMap = httpHeaders.getReadonlyHeaderMap(); - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME), HttpHeaders.PRODUCT_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME), HttpHeaders.PRODUCT_VERSION_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME), HttpHeaders.OS_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.APPLICATION_NAME_HEADER_NAME), "app-name"); - assertEquals(httpHeaderMap.get(HttpHeaders.APPLICATION_VERSION_HEADER_NAME), "app-version"); - assertEquals(httpHeaderMap.get(HttpHeaders.CORRELATION_ID_HEADER_NAME), "correlation-id"); + assertEquals(HttpHeaders.PRODUCT_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME)); + assertEquals(HttpHeaders.PRODUCT_VERSION_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME)); + assertEquals(HttpHeaders.OS_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME)); + assertEquals("app-name", httpHeaderMap.get(HttpHeaders.APPLICATION_NAME_HEADER_NAME)); + assertEquals("app-version", httpHeaderMap.get(HttpHeaders.APPLICATION_VERSION_HEADER_NAME)); + assertEquals("correlation-id", httpHeaderMap.get(HttpHeaders.CORRELATION_ID_HEADER_NAME)); } @Test @@ -66,9 +66,9 @@ void testHttpHeaderConstructor_valuesNotSet() { Map httpHeaderMap = httpHeaders.getReadonlyHeaderMap(); - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME), HttpHeaders.PRODUCT_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME), HttpHeaders.PRODUCT_VERSION_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME), HttpHeaders.OS_HEADER_VALUE); + assertEquals(HttpHeaders.PRODUCT_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME)); + assertEquals(HttpHeaders.PRODUCT_VERSION_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME)); + assertEquals(HttpHeaders.OS_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME)); assertNull(httpHeaderMap.get(HttpHeaders.APPLICATION_NAME_HEADER_NAME)); assertNull(httpHeaderMap.get(HttpHeaders.APPLICATION_VERSION_HEADER_NAME)); assertNotNull(httpHeaderMap.get(HttpHeaders.CORRELATION_ID_HEADER_NAME)); @@ -97,7 +97,7 @@ void testHttpHeaderConstructor_userIdentifierUPN() { Map httpHeaderMap = httpHeaders.getReadonlyHeaderMap(); String expectedValue = String.format(HttpHeaders.X_ANCHOR_MAILBOX_UPN_FORMAT, upn); - assertEquals(httpHeaderMap.get(HttpHeaders.X_ANCHOR_MAILBOX), expectedValue); + assertEquals(expectedValue, httpHeaderMap.get(HttpHeaders.X_ANCHOR_MAILBOX)); } @Test @@ -122,7 +122,7 @@ void testHttpHeaderConstructor_userIdentifierHomeAccountId() { Map httpHeaderMap = httpHeaders.getReadonlyHeaderMap(); - assertEquals(httpHeaderMap.get(HttpHeaders.X_ANCHOR_MAILBOX), "oid:userObjectId@userTenantId"); + assertEquals("oid:userObjectId@userTenantId", httpHeaderMap.get(HttpHeaders.X_ANCHOR_MAILBOX)); } @Test @@ -158,16 +158,16 @@ void testHttpHeaderConstructor_extraHttpHeadersOverwriteLibraryHeaders() { Map httpHeaderMap = httpHeaders.getReadonlyHeaderMap(); // Standard headers - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME), HttpHeaders.PRODUCT_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME), HttpHeaders.PRODUCT_VERSION_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME), HttpHeaders.OS_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.APPLICATION_VERSION_HEADER_NAME), "app-version"); - assertEquals(httpHeaderMap.get(HttpHeaders.CORRELATION_ID_HEADER_NAME), "correlation-id"); + assertEquals(HttpHeaders.PRODUCT_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME)); + assertEquals(HttpHeaders.PRODUCT_VERSION_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME)); + assertEquals(HttpHeaders.OS_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME)); + assertEquals("app-version", httpHeaderMap.get(HttpHeaders.APPLICATION_VERSION_HEADER_NAME)); + assertEquals("correlation-id", httpHeaderMap.get(HttpHeaders.CORRELATION_ID_HEADER_NAME)); // Overwritten standard header - assertEquals(httpHeaderMap.get(HttpHeaders.APPLICATION_NAME_HEADER_NAME), uniqueAppName); + assertEquals(uniqueAppName, httpHeaderMap.get(HttpHeaders.APPLICATION_NAME_HEADER_NAME)); // Extra header - assertEquals(httpHeaderMap.get(uniqueHeaderKey), uniqueHeaderValue); + assertEquals(uniqueHeaderValue, httpHeaderMap.get(uniqueHeaderKey)); } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MexParserTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MexParserTest.java index 131357add..f988563cc 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MexParserTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MexParserTest.java @@ -48,8 +48,8 @@ void testMexParsingWs13() throws Exception { } BindingPolicy endpoint = MexParser.getWsTrustEndpointFromMexResponse(sb.toString(), false); - assertEquals(endpoint.getUrl(), - "https://msft.sts.microsoft.com/adfs/services/trust/13/usernamemixed"); + assertEquals("https://msft.sts.microsoft.com/adfs/services/trust/13/usernamemixed", + endpoint.getUrl()); } @Test @@ -69,7 +69,7 @@ void testMexParsingWs2005() throws Exception { } BindingPolicy endpoint = MexParser.getWsTrustEndpointFromMexResponse(sb .toString(), false); - assertEquals(endpoint.getUrl(), "https://msft.sts.microsoft.com/adfs/services/trust/2005/usernamemixed"); + assertEquals("https://msft.sts.microsoft.com/adfs/services/trust/2005/usernamemixed", endpoint.getUrl()); } @Test @@ -89,7 +89,7 @@ void testMexParsingIntegrated() throws Exception { } BindingPolicy endpoint = MexParser.getPolicyFromMexResponseForIntegrated(sb .toString(), false); - assertEquals(endpoint.getUrl(), - "https://msft.sts.microsoft.com/adfs/services/trust/13/windowstransport"); + assertEquals("https://msft.sts.microsoft.com/adfs/services/trust/13/windowstransport", + endpoint.getUrl()); } } \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java index 551bd0513..ac3d64285 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java @@ -69,7 +69,7 @@ void telemetryManagerFlush_EventCountTest() { telemetryManager.flush(reqId, clientId); // 1 Default event, 1 API event, 1 Http event - assertEquals(eventsReceived.size(), 3); + assertEquals(3, eventsReceived.size()); } @Test @@ -94,7 +94,7 @@ void onSendFailureTrue_SkipEventsIfSuccessfulTest() { telemetryManager.flush(reqId, clientId); // API event was successful, so count should be 0 - assertEquals(eventsReceived.size(), 0); + assertEquals(0, eventsReceived.size()); eventsReceived.clear(); String reqId2 = telemetryManager.generateRequestId(); @@ -111,19 +111,19 @@ void onSendFailureTrue_SkipEventsIfSuccessfulTest() { telemetryManager.flush(reqId2, clientId); // API event failed, so count should be 3 (1 default, 1 Api, 1 http) - assertEquals(eventsReceived.size(), 3); + assertEquals(3, eventsReceived.size()); } @Test void telemetryInternalApi_ScrubTenantFromUriTest() throws Exception { - assertEquals(Event.scrubTenant(new URI("https://login.microsoftonline.com/common/oauth2/v2.0/token")), - "https://login.microsoftonline.com//oauth2/v2.0/token"); + assertEquals("https://login.microsoftonline.com//oauth2/v2.0/token", + Event.scrubTenant(new URI("https://login.microsoftonline.com/common/oauth2/v2.0/token"))); - assertEquals(Event.scrubTenant(new URI("https://login.microsoftonline.com/common")), - "https://login.microsoftonline.com/"); + assertEquals("https://login.microsoftonline.com/", + Event.scrubTenant(new URI("https://login.microsoftonline.com/common"))); - assertEquals(Event.scrubTenant(new URI("https://login.microsoftonline.com/tfp/msidlabb2c.onmicrosoft.com/B2C_1_ROPC_Auth")), - "https://login.microsoftonline.com/tfp//B2C_1_ROPC_Auth"); + assertEquals("https://login.microsoftonline.com/tfp//B2C_1_ROPC_Auth", + Event.scrubTenant(new URI("https://login.microsoftonline.com/tfp/msidlabb2c.onmicrosoft.com/B2C_1_ROPC_Auth"))); assertNull(Event.scrubTenant(new URI("https://msidlabb2c.b2clogin.com/tfp/msidlabb2c.onmicrosoft.com/B2C_1_ROPC_Auth"))); @@ -150,7 +150,7 @@ void telemetryContainsDefaultEventTest() { telemetryManager.flush(reqId, clientId); - assertEquals(eventsReceived.get(0).get("event_name"), "msal.default_event"); + assertEquals("msal.default_event", eventsReceived.get(0).get("event_name")); } @Test @@ -172,7 +172,7 @@ void telemetryFlushEventWithoutStopping_OrphanedEventIncludedTest() { telemetryManager.stopEvent(reqId, apiEvent1); telemetryManager.flush(reqId, clientId); - assertEquals(eventsReceived.size(), 3); + assertEquals(3, eventsReceived.size()); assertTrue(eventsReceived.stream().anyMatch(event -> event.get("event_name").equals("msal.http_event"))); } @@ -197,7 +197,7 @@ void telemetryStopEventWithoutStarting_NoExceptionThrownTest() { telemetryManager.flush(reqId, clientId); - assertEquals(eventsReceived.size(), 2); + assertEquals(2, eventsReceived.size()); assertFalse(eventsReceived.stream().anyMatch(event -> event.get("event_name").equals("msal.http_event"))); } @@ -217,7 +217,7 @@ void piiLoggingEnabled_ApiEventHashTest() { telemetryManager.stopEvent(reqId, apiEvent); assertNotNull(apiEvent.get("msal.tenant_id")); - assertNotEquals(apiEvent.get("msal.tenant_id"), tenantId); + assertNotEquals(tenantId, apiEvent.get("msal.tenant_id")); } @Test @@ -251,7 +251,7 @@ void authorityNotInTrustedHostList_AuthorityIsNullTest() throws URISyntaxExcepti apiEvent.setWasSuccessful(true); telemetryManager.stopEvent(reqId, apiEvent); - assertEquals(apiEvent.get("msal.authority"), "https://login.microsoftonline.com"); + assertEquals("https://login.microsoftonline.com", apiEvent.get("msal.authority")); ApiEvent apiEvent2 = new ApiEvent(false); @@ -268,10 +268,10 @@ void xmsCliTelemetryTest_CorrectFormatTest() { String responseHeader = "1,0,0,,"; XmsClientTelemetryInfo info = XmsClientTelemetryInfo.parseXmsTelemetryInfo(responseHeader); - assertEquals(info.getServerErrorCode(), "0"); - assertEquals(info.getServerSubErrorCode(), "0"); - assertEquals(info.getTokenAge(), ""); - assertEquals(info.getSpeInfo(), ""); + assertEquals("0", info.getServerErrorCode()); + assertEquals("0", info.getServerSubErrorCode()); + assertEquals("", info.getTokenAge()); + assertEquals("", info.getSpeInfo()); } @Test diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java index ac7437559..4742459ce 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java @@ -65,7 +65,7 @@ void executeOAuthRequest_SCBadRequestErrorInvalidGrant_InteractionRequiredExcept fail("Expected MsalServiceException was not thrown"); } catch (MsalInteractionRequiredException ex) { assertEquals(claims.replace("\\", ""), ex.claims()); - assertEquals(ex.reason(), InteractionRequiredExceptionReason.BASIC_ACTION); + assertEquals(InteractionRequiredExceptionReason.BASIC_ACTION, ex.reason()); } } @@ -239,7 +239,7 @@ void testExecuteOAuth_Success() throws MsalException, IOException, URISyntaxExce assertNotNull(result.account()); assertNotNull(result.account().homeAccountId()); - assertEquals(result.account().username(), "idlab@msidlab4.onmicrosoft.com"); + assertEquals("idlab@msidlab4.onmicrosoft.com", result.account().username()); assertFalse(StringHelper.isBlank(result.accessToken())); assertFalse(StringHelper.isBlank(result.refreshToken())); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java index dd020db81..cccd0d472 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java @@ -50,7 +50,7 @@ void escapeXMLElementDataTest() { String DATA_TO_ESCAPE = "o_!as & a34~'fe<> \" a1"; String XML_ESCAPED_DATA = "o_!as & a34~'fe<> " a1"; - assertEquals(WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE), XML_ESCAPED_DATA); + assertEquals(XML_ESCAPED_DATA, WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE)); assertEquals(WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE), StringEscapeUtils.escapeXml10(DATA_TO_ESCAPE)); } From d90a7d1344d491d0b7e925303805b88d046aaac7 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 2 Jun 2026 14:23:46 -0700 Subject: [PATCH 06/24] Better usage of test helpers --- .../aad/msal4j/AcquireTokenSilentIT.java | 6 ++-- .../aad/msal4j/ClientCredentialsIT.java | 29 +++++++------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java index 69c1d34f8..36379e2c7 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java @@ -107,8 +107,7 @@ void acquireTokenSilent_ConfidentialClient_acquireTokenSilent() throws Exception .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result); String cachedAt = result.accessToken(); @@ -133,8 +132,7 @@ void acquireTokenSilent_ConfidentialClient_acquireTokenSilentDifferentScopeThrow .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result); //Acquiring token for different scope, expect exception to be thrown assertThrows(ExecutionException.class, () -> cca.acquireTokenSilently(SilentParameters diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ClientCredentialsIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ClientCredentialsIT.java index 954c3b505..e2f970ce5 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ClientCredentialsIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ClientCredentialsIT.java @@ -75,8 +75,7 @@ void acquireTokenClientCredentials_Certificate_CiamCud() throws Exception { .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result); } @Test @@ -117,8 +116,7 @@ void acquireTokenClientCredentials_DefaultCacheLookup() throws Exception { .build()) .get(); - assertNotNull(result1); - assertNotNull(result1.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result1); IAuthenticationResult result2 = cca.acquireToken(ClientCredentialParameters .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) @@ -133,8 +131,7 @@ void acquireTokenClientCredentials_DefaultCacheLookup() throws Exception { .build()) .get(); - assertNotNull(result3); - assertNotNull(result3.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result3); assertNotEquals(result2.accessToken(), result3.accessToken()); } @@ -164,8 +161,7 @@ private void assertAcquireTokenCommon(String clientId, IClientCredential credent .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result); } private void assertAcquireTokenCommon_withParameters(AppConfig app, IClientCredential credential, IClientCredential credentialParam) throws Exception { @@ -180,8 +176,7 @@ private void assertAcquireTokenCommon_withParameters(AppConfig app, IClientCrede .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result); } private void assertAcquireTokenCommon_withRegion(AppConfig app, IClientCredential credential, String region, String regionalAuthority) throws Exception { @@ -202,8 +197,7 @@ private void assertAcquireTokenCommon_withRegion(AppConfig app, IClientCredentia .build()) .get(); - assertNotNull(resultNoRegion); - assertNotNull(resultNoRegion.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(resultNoRegion); assertEquals(TestConstants.MICROSOFT_AUTHORITY_BASIC_HOST, resultNoRegion.environment()); //Ensure regional tokens are properly cached and retrievable @@ -212,17 +206,15 @@ private void assertAcquireTokenCommon_withRegion(AppConfig app, IClientCredentia .build()) .get(); - assertNotNull(resultRegion); - assertNotNull(resultRegion.accessToken()); - assertEquals(resultRegion.environment(), regionalAuthority); + IntegrationTestHelper.assertAccessTokenNotNull(resultRegion); + assertEquals(regionalAuthority, resultRegion.environment()); IAuthenticationResult resultRegionCached = ccaRegion.acquireToken(ClientCredentialParameters .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) .build()) .get(); - assertNotNull(resultRegionCached); - assertNotNull(resultRegionCached.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(resultRegionCached); assertEquals(resultRegionCached.accessToken(), resultRegion.accessToken()); //Tokens retrieved from regional endpoints should be interchangeable with non-regional, and vice-versa @@ -233,8 +225,7 @@ private void assertAcquireTokenCommon_withRegion(AppConfig app, IClientCredentia .build()) .get(); - assertNotNull(resultNoRegion); - assertNotNull(resultNoRegion.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(resultNoRegion); assertEquals(resultNoRegion.accessToken(), resultRegion.accessToken()); } } From cca665ec69008c912c9a0016df04b9b7328a7620 Mon Sep 17 00:00:00 2001 From: avdunn Date: Thu, 4 Jun 2026 07:40:15 -0700 Subject: [PATCH 07/24] Update various test dependencies and build plugins --- msal4j-sdk/pom.xml | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/msal4j-sdk/pom.xml b/msal4j-sdk/pom.xml index 404494486..6d6065cd9 100644 --- a/msal4j-sdk/pom.xml +++ b/msal4j-sdk/pom.xml @@ -60,7 +60,7 @@ org.slf4j slf4j-simple - 1.6.2 + 1.7.36 test @@ -96,7 +96,7 @@ net.bytebuddy byte-buddy - 1.14.5 + 1.17.5 test @@ -126,7 +126,7 @@ commons-io commons-io - 2.14.0 + 2.22.0 test @@ -169,7 +169,7 @@ org.revapi revapi-maven-plugin - 0.15.0 + 0.15.1 @@ -183,7 +183,7 @@ org.revapi revapi-java - 0.28.1 + 0.28.4 @@ -199,7 +199,7 @@ org.apache.maven.plugins maven-jar-plugin - 2.5 + 3.5.0 @@ -213,7 +213,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.5.2 + 3.5.6 @{argLine} -noverify ${skip.unit.tests} @@ -222,7 +222,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.1.0 + 3.12.0 src/main/java @@ -238,7 +238,7 @@ org.apache.maven.plugins maven-source-plugin - 2.2.1 + 3.4.0 attach-sources @@ -251,12 +251,12 @@ com.github.spotbugs spotbugs-maven-plugin - 3.1.11 + 4.2.3 org.apache.maven.plugins maven-compiler-plugin - 3.7.0 + 3.15.0 8 8 @@ -265,7 +265,7 @@ org.codehaus.mojo build-helper-maven-plugin - 1.10 + 3.6.0 add-test-source @@ -284,7 +284,7 @@ org.apache.maven.plugins maven-failsafe-plugin - 3.5.0 + 3.5.6 @@ -303,7 +303,7 @@ biz.aQute.bnd bnd-maven-plugin - 5.2.0 + 6.4.0 @@ -315,7 +315,7 @@ org.jacoco jacoco-maven-plugin - 0.8.12 + 0.8.13 @@ -360,7 +360,7 @@ maven-dependency-plugin - 3.1.2 + 3.11.0 From 356808a363fa2aca6872aeea5e6782c2df84d18e Mon Sep 17 00:00:00 2001 From: avdunn Date: Thu, 4 Jun 2026 08:30:27 -0700 Subject: [PATCH 08/24] Update selenium and related tests --- msal4j-sdk/pom.xml | 14 +++++++++++++- .../java/infrastructure/SeleniumExtensions.java | 4 ++-- .../infrastructure/pageobjects/ADFSLoginPage.java | 2 +- .../pageobjects/AzureADLoginPage.java | 8 ++++---- .../pageobjects/B2CLocalLoginPage.java | 2 +- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/msal4j-sdk/pom.xml b/msal4j-sdk/pom.xml index 6d6065cd9..5dc258edc 100644 --- a/msal4j-sdk/pom.xml +++ b/msal4j-sdk/pom.xml @@ -114,7 +114,19 @@ org.seleniumhq.selenium selenium-java - 3.14.0 + 4.13.0 + test + + + com.squareup.okhttp3 + okhttp + 3.14.9 + test + + + org.apache.httpcomponents + httpclient + 4.5.14 test diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java index 7a171b05b..c61a6b01b 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java @@ -28,14 +28,14 @@ private SeleniumExtensions() { public static WebDriver createDefaultWebDriver() { ChromeOptions options = new ChromeOptions(); - options.addArguments("--headless"); + options.addArguments("--headless=new"); options.addArguments("--incognito"); return new ChromeDriver(options); } public static WebElement waitForElementToBeVisibleAndEnabled(WebDriver driver, By by, Duration timeout) { - WebDriverWait wait = new WebDriverWait(driver, timeout.getSeconds()); + WebDriverWait wait = new WebDriverWait(driver, timeout); return wait.until(ExpectedConditions.elementToBeClickable(by)); } diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java index ec7ccd0ad..acff33308 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java @@ -32,7 +32,7 @@ public class ADFSLoginPage { public ADFSLoginPage(WebDriver driver) { this.driver = driver; - this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT.getSeconds()); + this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT); } /** diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java index dcaab6359..aabd8289b 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java @@ -43,7 +43,7 @@ public class AzureADLoginPage { public AzureADLoginPage(WebDriver driver) { this.driver = driver; - this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT.getSeconds()); + this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT); } /** @@ -103,7 +103,7 @@ public AzureADLoginPage clickSubmit() { */ public boolean isAuthenticationComplete() { try { - WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT.getSeconds()); + WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT); shortWait.until(ExpectedConditions.textToBePresentInElementLocated( AUTH_COMPLETE_BODY, AUTH_COMPLETE_TEXT)); LOG.info("Authentication complete page detected"); @@ -150,7 +150,7 @@ private void handleOptionalPrompts() { private void handleAreYouTryingToSignInPrompt() { try { LOG.info("Checking for 'Are you trying to sign in' prompt"); - WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT.getSeconds()); + WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT); shortWait.until(ExpectedConditions.elementToBeClickable(ARE_YOU_TRYING_TO_SIGN_IN_BUTTON)) .click(); LOG.info("Clicked Continue on 'Are you trying to sign in' prompt"); @@ -165,7 +165,7 @@ private void handleAreYouTryingToSignInPrompt() { private void handleStaySignedInPrompt() { try { LOG.info("Checking for 'Stay signed in' prompt"); - WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT.getSeconds()); + WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT); shortWait.until(ExpectedConditions.elementToBeClickable(STAY_SIGNED_IN_NO_BUTTON)) .click(); LOG.info("Clicked No on 'Stay signed in' prompt"); diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java index 8b40c1388..450bf9dca 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java @@ -33,7 +33,7 @@ public class B2CLocalLoginPage { public B2CLocalLoginPage(WebDriver driver) { this.driver = driver; - this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT.getSeconds()); + this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT); } /** From 61294da8e01f60eaf734018a8f800d40a6fc16d5 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 5 Jun 2026 13:46:28 -0700 Subject: [PATCH 09/24] Add Selenium diagnostics, CIAM page object, resilient locators, and remove broken B2C browser tests - Upgrade Selenium 3.14.0 to 4.13.0 (last Java 8-compatible) - Add SeleniumDiagnostics: screenshot, HTML dump, browser log capture on failure - Add SeleniumTestWatcher (AfterTestExecutionCallback) for auto-diagnostics - Add WebDriverProvider interface for test extension access to WebDriver - Add CIAMLoginPage page object for CIAM-specific login flows - Add findWithFallback() helper for resilient element locators - Add fallback locator arrays to AzureADLoginPage and ADFSLoginPage - Add failsafe rerunFailingTestsCount=1 for transient browser flakiness - Enable browser console logging via goog:loggingPrefs - Fix DeviceCodeIT close() to quit() for proper WebDriver cleanup - Route CIAM/OIDC authorities to correct page objects in SeleniumTest - Fix scope bug in AcquireTokenInteractiveIT (string literal vs constant) - Remove broken B2C Selenium tests (B2C coverage via ROPC tests instead) - Remove B2CLocalLoginPage and related dead code/constants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- msal4j-sdk/pom.xml | 1 + .../aad/msal4j/AcquireTokenInteractiveIT.java | 36 +--- .../aad/msal4j/AuthorizationCodeIT.java | 50 +---- .../microsoft/aad/msal4j/DeviceCodeIT.java | 17 +- .../microsoft/aad/msal4j/SeleniumTest.java | 25 ++- .../microsoft/aad/msal4j/TestConstants.java | 7 - .../infrastructure/SeleniumDiagnostics.java | 186 ++++++++++++++++++ .../infrastructure/SeleniumExtensions.java | 61 +++++- .../infrastructure/SeleniumTestWatcher.java | 70 +++++++ .../infrastructure/WebDriverProvider.java | 21 ++ .../pageobjects/ADFSLoginPage.java | 34 ++-- .../pageobjects/AzureADLoginPage.java | 22 ++- .../pageobjects/B2CLocalLoginPage.java | 104 ---------- .../pageobjects/CIAMLoginPage.java | 128 ++++++++++++ 14 files changed, 538 insertions(+), 224 deletions(-) create mode 100644 msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumDiagnostics.java create mode 100644 msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumTestWatcher.java create mode 100644 msal4j-sdk/src/integrationtest/java/infrastructure/WebDriverProvider.java delete mode 100644 msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java create mode 100644 msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/CIAMLoginPage.java diff --git a/msal4j-sdk/pom.xml b/msal4j-sdk/pom.xml index 5dc258edc..e25b4781c 100644 --- a/msal4j-sdk/pom.xml +++ b/msal4j-sdk/pom.xml @@ -307,6 +307,7 @@ ${skip.integration.tests} + 1 ${adfs.disabled} diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenInteractiveIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenInteractiveIT.java index 12da8ee35..afd3aa70c 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenInteractiveIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenInteractiveIT.java @@ -62,22 +62,6 @@ void acquireTokenInteractive_ADFSv2022() { assertAcquireTokenCommon(user, app.getAppId(), app.getAuthority() + "organizations/", TestConstants.ADFS_SCOPE); } - @Test - void acquireTokenWithAuthorizationCode_B2C_Local() { - AppConfig app = LabResponseHelper.getAppConfig(APP_B2C); - UserConfig user = LabResponseHelper.getUserConfig(USER_B2C); - - assertAcquireTokenB2C(user, TestConstants.B2C_AUTHORITY, app.getAppId()); - } - - @Test - void acquireTokenWithAuthorizationCode_B2C_LegacyFormat() { - AppConfig app = LabResponseHelper.getAppConfig(APP_B2C); - UserConfig user = LabResponseHelper.getUserConfig(USER_B2C); - - assertAcquireTokenB2C(user, TestConstants.B2C_AUTHORITY_LEGACY_FORMAT, app.getAppId()); - } - @Test void acquireTokenInteractive_ManagedUser_InstanceAware() { AppConfig app = LabResponseHelper.getAppConfig(APP_ARLINGTON); @@ -86,7 +70,7 @@ void acquireTokenInteractive_ManagedUser_InstanceAware() { assertAcquireTokenInstanceAware(user, app.getAppId(), TestConstants.ARLINGTON_TENANT_ID); } - //@Test -disabled to avoid test failures, HTML page seems to have changed as this test cannot find the username input element + @Test void acquireTokenInteractive_Ciam() { AppConfig app = LabResponseHelper.getAppConfig(APP_CIAM); UserConfig user = LabResponseHelper.getUserConfig(USER_CIAM); @@ -115,7 +99,7 @@ void acquireTokenInteractive_Ciam() { InteractiveRequestParameters parameters = InteractiveRequestParameters .builder(url) - .scopes(Collections.singleton("TestConstants.USER_READ_SCOPE")) + .scopes(Collections.singleton(TestConstants.USER_READ_SCOPE)) .extraQueryParameters(extraQueryParameters) .systemBrowserOptions(browserOptions) .build(); @@ -143,22 +127,6 @@ private void assertAcquireTokenCommon(UserConfig user, String appId, String auth assertEquals(user.getUpn(), result.account().username()); } - private void assertAcquireTokenB2C(UserConfig user, String authority, String appId) { - - PublicClientApplication pca; - try { - pca = PublicClientApplication.builder( - appId). - b2cAuthority(authority + TestConstants.B2C_SIGN_IN_POLICY). - build(); - } catch (MalformedURLException ex) { - throw new RuntimeException(ex.getMessage()); - } - - IAuthenticationResult result = acquireTokenInteractive(user, pca, appId); - IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); - } - private void assertAcquireTokenInstanceAware(UserConfig user, String appId, String tenantId) { PublicClientApplication pca = IntegrationTestHelper.createPublicApp(appId, TestConstants.MICROSOFT_AUTHORITY_HOST + tenantId); diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AuthorizationCodeIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AuthorizationCodeIT.java index 58bc72a57..ff7c96fc9 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AuthorizationCodeIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AuthorizationCodeIT.java @@ -45,15 +45,7 @@ public void acquireTokenWithAuthorizationCode_ManagedUser() { assertAcquireTokenAAD(user, app.getAppId(), null); } - //Temporarily disabling: no change in the library, but started seeing "The service has encountered an internal error. Please reauthenticate and try again." - //Needs investigation, tracked in https://github.com/AzureAD/microsoft-authentication-library-for-java/issues/1023 - //@Test - public void acquireTokenWithAuthorizationCode_B2C_Local() { - UserConfig user = LabResponseHelper.getUserConfig(USER_B2C); - assertAcquireTokenB2C(user); - } - - // @Test Disabled, the browser automation suddenly started failing without underlying code changes and needs investigation: https://github.com/AzureAD/microsoft-authentication-library-for-java/issues/1010 + @Test public void acquireTokenWithAuthorizationCode_CiamCud() throws Exception { String authorityCud = "https://login.msidlabsciam.com/fe362aec-5d43-45d1-b730-9755e60dc3b9/v2.0/"; @@ -131,28 +123,6 @@ private void assertAcquireTokenAAD(UserConfig user, String appId, Map + * Captures screenshots, page source (HTML), and browser console logs to help diagnose + * failures in browser-based integration tests. All capture methods are fail-safe and will + * not throw exceptions, so they can be called safely during test teardown without masking + * the original test failure. + *

+ * Output is written to {@code target/selenium-diagnostics/} with filenames that include + * the test name and a timestamp for uniqueness. + */ +public final class SeleniumDiagnostics { + + private static final Logger LOG = LoggerFactory.getLogger(SeleniumDiagnostics.class); + private static final String OUTPUT_DIR = "target/selenium-diagnostics"; + + private SeleniumDiagnostics() { + } + + /** + * Capture all available diagnostics (screenshot, page source, browser logs) for a failed test. + * + * @param driver the WebDriver instance (may be null) + * @param testName the name of the test method that failed + */ + public static void captureAll(WebDriver driver, String testName) { + if (driver == null) { + LOG.warn("Cannot capture diagnostics: WebDriver is null"); + return; + } + + String sanitizedName = sanitizeFileName(testName); + String timestamp = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date()); + String filePrefix = sanitizedName + "-" + timestamp; + + captureScreenshot(driver, filePrefix); + capturePageSource(driver, filePrefix); + captureBrowserLogs(driver, filePrefix); + } + + /** + * Capture a screenshot of the current browser state. + * + * @param driver the WebDriver instance + * @param filePrefix the file name prefix (test name + timestamp) + */ + static void captureScreenshot(WebDriver driver, String filePrefix) { + try { + if (!(driver instanceof TakesScreenshot)) { + LOG.warn("WebDriver does not support screenshots"); + return; + } + + File screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); + Path destination = getOutputPath(filePrefix + ".png"); + Files.copy(screenshot.toPath(), destination, StandardCopyOption.REPLACE_EXISTING); + LOG.info("Screenshot saved: {}", destination); + } catch (Exception e) { + LOG.warn("Failed to capture screenshot: {}", e.getMessage()); + } + } + + /** + * Capture the current page source (HTML) for element inspection. + *

+ * Sensitive parameters (auth codes, tokens, etc.) in URLs and form actions are redacted. + * + * @param driver the WebDriver instance + * @param filePrefix the file name prefix (test name + timestamp) + */ + static void capturePageSource(WebDriver driver, String filePrefix) { + try { + String pageSource = driver.getPageSource(); + if (pageSource == null || pageSource.isEmpty()) { + LOG.warn("Page source is empty"); + return; + } + + String currentUrl = driver.getCurrentUrl(); + String redactedUrl = redactSensitiveParams(currentUrl); + + StringBuilder content = new StringBuilder(); + content.append("\n"); + content.append("\n"); + content.append(pageSource); + + Path destination = getOutputPath(filePrefix + ".html"); + Files.write(destination, content.toString().getBytes("UTF-8")); + LOG.info("Page source saved: {}", destination); + } catch (Exception e) { + LOG.warn("Failed to capture page source: {}", e.getMessage()); + } + } + + /** + * Capture browser console logs (JavaScript errors, network issues, etc.). + * + * @param driver the WebDriver instance + * @param filePrefix the file name prefix (test name + timestamp) + */ + static void captureBrowserLogs(WebDriver driver, String filePrefix) { + try { + List logs = driver.manage().logs().get(LogType.BROWSER).getAll(); + + if (logs.isEmpty()) { + LOG.debug("No browser console logs to capture"); + return; + } + + StringBuilder content = new StringBuilder(); + content.append("Browser Console Logs\n"); + content.append("====================\n\n"); + + for (LogEntry entry : logs) { + content.append("[").append(entry.getLevel()).append("] "); + content.append(new SimpleDateFormat("HH:mm:ss.SSS").format(new Date(entry.getTimestamp()))); + content.append(" - ").append(redactSensitiveParams(entry.getMessage())); + content.append("\n"); + } + + Path destination = getOutputPath(filePrefix + "-console.log"); + Files.write(destination, content.toString().getBytes("UTF-8")); + LOG.info("Browser logs saved: {}", destination); + } catch (Exception e) { + LOG.warn("Failed to capture browser logs: {} (this may be expected if logging prefs are not supported)", e.getMessage()); + } + } + + /** + * Get the output path for a diagnostic file, creating the directory if needed. + */ + private static Path getOutputPath(String fileName) throws IOException { + Path dir = Paths.get(OUTPUT_DIR); + if (!Files.exists(dir)) { + Files.createDirectories(dir); + } + return dir.resolve(fileName); + } + + /** + * Sanitize a test name to be safe for use as a file name on all platforms. + * Removes/replaces characters that are invalid in Windows file paths. + */ + private static String sanitizeFileName(String name) { + if (name == null) { + return "unknown"; + } + return name.replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + /** + * Redact sensitive OAuth parameters from URLs and log messages. + * Replaces values of known sensitive query parameters with "[REDACTED]". + */ + public static String redactSensitiveParams(String text) { + if (text == null) { + return ""; + } + return text.replaceAll( + "(?i)(code|access_token|id_token|refresh_token|client_secret|password|assertion)=([^&\\s\"']*)", + "$1=[REDACTED]"); + } +} diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java index c61a6b01b..25bcb8da2 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java @@ -6,18 +6,21 @@ import com.microsoft.aad.msal4j.labapi.UserConfig; import infrastructure.pageobjects.ADFSLoginPage; import infrastructure.pageobjects.AzureADLoginPage; -import infrastructure.pageobjects.B2CLocalLoginPage; +import infrastructure.pageobjects.CIAMLoginPage; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.logging.LogType; +import org.openqa.selenium.logging.LoggingPreferences; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Duration; +import java.util.logging.Level; public class SeleniumExtensions { @@ -31,6 +34,11 @@ public static WebDriver createDefaultWebDriver() { options.addArguments("--headless=new"); options.addArguments("--incognito"); + // Enable browser console logging for diagnostic capture on failure + LoggingPreferences logPrefs = new LoggingPreferences(); + logPrefs.enable(LogType.BROWSER, Level.ALL); + options.setCapability("goog:loggingPrefs", logPrefs); + return new ChromeDriver(options); } @@ -39,6 +47,51 @@ public static WebElement waitForElementToBeVisibleAndEnabled(WebDriver driver, B return wait.until(ExpectedConditions.elementToBeClickable(by)); } + /** + * Wait for any one of several locators to match a clickable element, returning the first match. + *

+ * This uses a single wait loop that checks all locators on each poll cycle, so the total + * wait time is bounded by {@code timeout} regardless of how many locators are provided. + * When a fallback locator matches (not the primary), a warning is logged to flag that + * the page structure may have changed. + * + * @param driver the WebDriver instance + * @param timeout maximum time to wait for any locator to match + * @param locators one or more locators to try, in priority order (primary first) + * @return the first clickable WebElement found + * @throws org.openqa.selenium.TimeoutException if no locator matches within the timeout + */ + public static WebElement findWithFallback(WebDriver driver, Duration timeout, By... locators) { + if (locators.length == 0) { + throw new IllegalArgumentException("At least one locator must be provided"); + } + + if (locators.length == 1) { + return waitForElementToBeVisibleAndEnabled(driver, locators[0], timeout); + } + + WebDriverWait wait = new WebDriverWait(driver, timeout); + final By primaryLocator = locators[0]; + + return wait.until(d -> { + for (By locator : locators) { + try { + WebElement element = d.findElement(locator); + if (element != null && element.isDisplayed() && element.isEnabled()) { + if (!locator.equals(primaryLocator)) { + LOG.warn("Primary locator {} not found, matched fallback: {}", + primaryLocator, locator); + } + return element; + } + } catch (Exception ignored) { + // Element not found with this locator, try next + } + } + return null; + }); + } + public static void performADOrCiamLogin(WebDriver driver, UserConfig user) { LOG.info("performADOrCiamLogin for user: {}", user.getUpn()); @@ -53,10 +106,10 @@ public static void performADFSLogin(WebDriver driver, UserConfig user) { loginPage.login(user.getUpn(), user.getPassword()); } - public static void performLocalLogin(WebDriver driver, UserConfig user) { - LOG.info("performLocalLogin"); + public static void performCiamLogin(WebDriver driver, UserConfig user) { + LOG.info("performCiamLogin for user: {}", user.getUpn()); - B2CLocalLoginPage loginPage = new B2CLocalLoginPage(driver); + CIAMLoginPage loginPage = new CIAMLoginPage(driver); loginPage.login(user.getUpn(), user.getPassword()); } diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumTestWatcher.java b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumTestWatcher.java new file mode 100644 index 000000000..972054dc5 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumTestWatcher.java @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package infrastructure; + +import org.junit.jupiter.api.extension.AfterTestExecutionCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.openqa.selenium.WebDriver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JUnit 5 extension that captures Selenium diagnostics (screenshot, page source, browser logs) + * when a test fails. + *

+ * This extension uses {@link AfterTestExecutionCallback} which runs after the test method + * but before {@code @AfterEach} teardown. This ordering is critical because it ensures + * the WebDriver is still alive when diagnostics are captured — the driver is typically closed + * in {@code @AfterEach}. + *

+ * Test classes must implement {@link WebDriverProvider} to expose their WebDriver instance. + * If the test class does not implement the interface, or the driver is null, diagnostics + * are silently skipped. + *

+ * Usage: + *

+ * {@literal @}ExtendWith(SeleniumTestWatcher.class)
+ * class MySeleniumTest implements WebDriverProvider {
+ *     WebDriver driver;
+ *
+ *     public WebDriver getWebDriver() { return driver; }
+ * }
+ * 
+ * + * @see SeleniumDiagnostics + * @see WebDriverProvider + */ +public class SeleniumTestWatcher implements AfterTestExecutionCallback { + + private static final Logger LOG = LoggerFactory.getLogger(SeleniumTestWatcher.class); + + @Override + public void afterTestExecution(ExtensionContext context) { + // Only capture diagnostics if the test failed + if (!context.getExecutionException().isPresent()) { + return; + } + + Object testInstance = context.getTestInstance().orElse(null); + if (!(testInstance instanceof WebDriverProvider)) { + LOG.debug("Test class does not implement WebDriverProvider, skipping diagnostics"); + return; + } + + WebDriver driver = ((WebDriverProvider) testInstance).getWebDriver(); + if (driver == null) { + LOG.warn("WebDriver is null, cannot capture diagnostics for failed test: {}", + context.getDisplayName()); + return; + } + + String testName = context.getTestClass() + .map(Class::getSimpleName) + .orElse("UnknownClass") + + "." + context.getDisplayName(); + + LOG.info("Test failed: {} — capturing diagnostics", testName); + SeleniumDiagnostics.captureAll(driver, testName); + } +} diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/WebDriverProvider.java b/msal4j-sdk/src/integrationtest/java/infrastructure/WebDriverProvider.java new file mode 100644 index 000000000..dec688016 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/WebDriverProvider.java @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package infrastructure; + +import org.openqa.selenium.WebDriver; + +/** + * Interface for test classes that manage a WebDriver instance. + * Used by {@link SeleniumTestWatcher} to access the driver for diagnostics on test failure. + */ +public interface WebDriverProvider { + + /** + * Returns the current WebDriver instance, or null if the driver has not been initialized + * or has already been closed. + * + * @return the WebDriver instance, or null + */ + WebDriver getWebDriver(); +} diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java index acff33308..aaa30a4e8 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java @@ -3,10 +3,9 @@ package infrastructure.pageobjects; +import infrastructure.SeleniumExtensions; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; -import org.openqa.selenium.support.ui.ExpectedConditions; -import org.openqa.selenium.support.ui.WebDriverWait; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,24 +14,37 @@ /** * Page Object Model for ADFS login page. * Represents the Active Directory Federation Services authentication flow. + *

+ * Uses fallback locators to handle ADFS version differences where element IDs + * may vary between ADFS 2019, 2022, and other versions. */ public class ADFSLoginPage { private static final Logger LOG = LoggerFactory.getLogger(ADFSLoginPage.class); private final WebDriver driver; - private final WebDriverWait wait; - // Element locators - private static final By USERNAME_INPUT = By.id("userNameInput"); - private static final By PASSWORD_INPUT = By.id("passwordInput"); - private static final By SUBMIT_BUTTON = By.id("submitButton"); + // Element locators with fallbacks for different ADFS versions + private static final By[] USERNAME_LOCATORS = { + By.id("userNameInput"), + By.id("ContentPlaceHolder1_UsernameTextBox"), + By.cssSelector("input[type='text'][name='UserName']"), + }; + private static final By[] PASSWORD_LOCATORS = { + By.id("passwordInput"), + By.id("ContentPlaceHolder1_PasswordTextBox"), + By.cssSelector("input[type='password'][name='Password']"), + }; + private static final By[] SUBMIT_LOCATORS = { + By.id("submitButton"), + By.id("ContentPlaceHolder1_SubmitButton"), + By.cssSelector("span[id='submitButton']"), + }; private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15); public ADFSLoginPage(WebDriver driver) { this.driver = driver; - this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT); } /** @@ -43,7 +55,7 @@ public ADFSLoginPage(WebDriver driver) { */ public ADFSLoginPage enterUsername(String username) { LOG.info("Entering username: {}", username); - wait.until(ExpectedConditions.elementToBeClickable(USERNAME_INPUT)) + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, USERNAME_LOCATORS) .sendKeys(username); return this; } @@ -56,7 +68,7 @@ public ADFSLoginPage enterUsername(String username) { */ public ADFSLoginPage enterPassword(String password) { LOG.info("Entering password"); - wait.until(ExpectedConditions.elementToBeClickable(PASSWORD_INPUT)) + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, PASSWORD_LOCATORS) .sendKeys(password); return this; } @@ -68,7 +80,7 @@ public ADFSLoginPage enterPassword(String password) { */ public ADFSLoginPage clickSubmit() { LOG.info("Clicking submit button"); - wait.until(ExpectedConditions.elementToBeClickable(SUBMIT_BUTTON)) + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, SUBMIT_LOCATORS) .click(); return this; } diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java index aabd8289b..69b609ade 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java @@ -3,6 +3,7 @@ package infrastructure.pageobjects; +import infrastructure.SeleniumExtensions; import org.openqa.selenium.By; import org.openqa.selenium.TimeoutException; import org.openqa.selenium.WebDriver; @@ -16,6 +17,9 @@ /** * Page Object Model for Azure AD login page. * Represents the standard Azure AD authentication flow. + *

+ * Uses fallback locators for username/password fields to handle variations + * in the Azure AD login page across different tenants and configurations. */ public class AzureADLoginPage { @@ -24,9 +28,17 @@ public class AzureADLoginPage { private final WebDriver driver; private final WebDriverWait wait; - // Element locators - private static final By USERNAME_INPUT = By.id("i0116"); - private static final By PASSWORD_INPUT = By.id("i0118"); + // Element locators with fallbacks + private static final By[] USERNAME_LOCATORS = { + By.id("i0116"), + By.name("loginfmt"), + By.cssSelector("input[type='email'][name='loginfmt']"), + }; + private static final By[] PASSWORD_LOCATORS = { + By.id("i0118"), + By.name("passwd"), + By.cssSelector("input[type='password'][name='passwd']"), + }; private static final By NEXT_BUTTON = By.id("idSIButton9"); private static final By SUBMIT_BUTTON = By.id("idSIButton9"); @@ -54,7 +66,7 @@ public AzureADLoginPage(WebDriver driver) { */ public AzureADLoginPage enterUsername(String username) { LOG.info("Entering username: {}", username); - wait.until(ExpectedConditions.elementToBeClickable(USERNAME_INPUT)) + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, USERNAME_LOCATORS) .sendKeys(username); return this; } @@ -79,7 +91,7 @@ public AzureADLoginPage clickNext() { */ public AzureADLoginPage enterPassword(String password) { LOG.info("Entering password"); - wait.until(ExpectedConditions.elementToBeClickable(PASSWORD_INPUT)) + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, PASSWORD_LOCATORS) .sendKeys(password); return this; } diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java deleted file mode 100644 index 450bf9dca..000000000 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package infrastructure.pageobjects; - -import org.openqa.selenium.By; -import org.openqa.selenium.WebDriver; -import org.openqa.selenium.support.ui.ExpectedConditions; -import org.openqa.selenium.support.ui.WebDriverWait; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Duration; - -/** - * Page Object Model for B2C Local Account login page. - * Represents the Azure AD B2C local account authentication flow. - */ -public class B2CLocalLoginPage { - - private static final Logger LOG = LoggerFactory.getLogger(B2CLocalLoginPage.class); - - private final WebDriver driver; - private final WebDriverWait wait; - - // Element locators - private static final By LOCAL_ACCOUNT_BUTTON = By.id("SignInWithLogonNameExchange"); - private static final By USERNAME_INPUT = By.id("cred_userid_inputtext"); - private static final By PASSWORD_INPUT = By.id("cred_password_inputtext"); - private static final By SIGN_IN_BUTTON = By.id("cred_sign_in_button"); - - private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15); - - public B2CLocalLoginPage(WebDriver driver) { - this.driver = driver; - this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT); - } - - /** - * Click the local account sign-in option. - * - * @return This page object for method chaining - */ - public B2CLocalLoginPage clickLocalAccount() { - LOG.info("Clicking local account button"); - wait.until(ExpectedConditions.elementToBeClickable(LOCAL_ACCOUNT_BUTTON)) - .click(); - return this; - } - - /** - * Enter the username in the B2C login page. - * - * @param username The username to enter - * @return This page object for method chaining - */ - public B2CLocalLoginPage enterUsername(String username) { - LOG.info("Entering username: {}", username); - wait.until(ExpectedConditions.elementToBeClickable(USERNAME_INPUT)) - .sendKeys(username); - return this; - } - - /** - * Enter the password in the B2C login page. - * - * @param password The password to enter - * @return This page object for method chaining - */ - public B2CLocalLoginPage enterPassword(String password) { - LOG.info("Entering password"); - wait.until(ExpectedConditions.elementToBeClickable(PASSWORD_INPUT)) - .sendKeys(password); - return this; - } - - /** - * Click the Sign in button to complete login. - * - * @return This page object for method chaining - */ - public B2CLocalLoginPage clickSignIn() { - LOG.info("Clicking sign in button"); - wait.until(ExpectedConditions.elementToBeClickable(SIGN_IN_BUTTON)) - .click(); - return this; - } - - /** - * Perform a complete B2C local account login flow. - * This is a convenience method that chains all the necessary steps. - * - * @param username The username - * @param password The password - */ - public void login(String username, String password) { - clickLocalAccount() - .enterUsername(username) - .enterPassword(password) - .clickSignIn(); - - LOG.info("B2C local login completed"); - } -} diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/CIAMLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/CIAMLoginPage.java new file mode 100644 index 000000000..6fc26cede --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/CIAMLoginPage.java @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package infrastructure.pageobjects; + +import infrastructure.SeleniumExtensions; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; + +/** + * Page Object Model for CIAM (Customer Identity and Access Management) login page. + * Represents the Azure AD CIAM authentication flow, which uses a multi-step form + * with different element IDs than the standard Azure AD login page. + *

+ * CIAM login flow: + * 1. Enter email address + * 2. Click Next + * 3. Enter password + * 4. Click Sign in + *

+ * Uses fallback locators to handle variations in CIAM tenant configurations. + */ +public class CIAMLoginPage { + + private static final Logger LOG = LoggerFactory.getLogger(CIAMLoginPage.class); + + private final WebDriver driver; + + // Username step locators + private static final By[] USERNAME_LOCATORS = { + By.name("username"), + By.cssSelector("input[type='text'][autocomplete*='username']"), + By.cssSelector("input[type='email']"), + }; + private static final By[] NEXT_BUTTON_LOCATORS = { + By.id("usernamePrimaryButton"), + By.cssSelector("button[type='submit'][aria-label='Next']"), + By.cssSelector("button.ext-primary[type='submit']"), + }; + + // Password step locators + private static final By[] PASSWORD_LOCATORS = { + By.name("password"), + By.id("password"), + By.cssSelector("input[type='password']"), + }; + private static final By[] SIGN_IN_BUTTON_LOCATORS = { + By.id("passwordPrimaryButton"), + By.cssSelector("button[type='submit'][aria-label='Sign in']"), + By.cssSelector("button.ext-primary[type='submit']"), + }; + + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15); + + public CIAMLoginPage(WebDriver driver) { + this.driver = driver; + } + + /** + * Enter the username/email in the CIAM login page. + * + * @param username The email address to enter + * @return This page object for method chaining + */ + public CIAMLoginPage enterUsername(String username) { + LOG.info("Entering username: {}", username); + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, USERNAME_LOCATORS) + .sendKeys(username); + return this; + } + + /** + * Click the Next button after entering the username. + * + * @return This page object for method chaining + */ + public CIAMLoginPage clickNext() { + LOG.info("Clicking Next button"); + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, NEXT_BUTTON_LOCATORS) + .click(); + return this; + } + + /** + * Enter the password in the CIAM login page. + * + * @param password The password to enter + * @return This page object for method chaining + */ + public CIAMLoginPage enterPassword(String password) { + LOG.info("Entering password"); + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, PASSWORD_LOCATORS) + .sendKeys(password); + return this; + } + + /** + * Click the Sign in button to complete login. + * + * @return This page object for method chaining + */ + public CIAMLoginPage clickSignIn() { + LOG.info("Clicking Sign in button"); + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, SIGN_IN_BUTTON_LOCATORS) + .click(); + return this; + } + + /** + * Perform a complete CIAM login flow. + * This is a convenience method that chains all the necessary steps. + * + * @param username The email address + * @param password The password + */ + public void login(String username, String password) { + enterUsername(username) + .clickNext() + .enterPassword(password) + .clickSignIn(); + + LOG.info("CIAM login completed"); + } +} From 8666c86944073a4857f9f8b990fa752d1fa84a87 Mon Sep 17 00:00:00 2001 From: avdunn Date: Sun, 7 Jun 2026 15:18:45 -0700 Subject: [PATCH 10/24] Address PR review feedback: fix remaining reversed assertEquals args, use StandardCharsets.UTF_8 - Fix reversed assertEquals in WSTrustRequestTest (StringEscapeUtils as expected) - Fix reversed assertEquals in HelperAndUtilityTest (convertedInternalMap.size as expected) - Use StandardCharsets.UTF_8 instead of string literal in SeleniumDiagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/infrastructure/SeleniumDiagnostics.java | 5 +++-- .../java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java | 2 +- .../java/com/microsoft/aad/msal4j/WSTrustRequestTest.java | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumDiagnostics.java b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumDiagnostics.java index 996d139f9..cdae235d3 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumDiagnostics.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumDiagnostics.java @@ -13,6 +13,7 @@ import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -108,7 +109,7 @@ static void capturePageSource(WebDriver driver, String filePrefix) { content.append(pageSource); Path destination = getOutputPath(filePrefix + ".html"); - Files.write(destination, content.toString().getBytes("UTF-8")); + Files.write(destination, content.toString().getBytes(StandardCharsets.UTF_8)); LOG.info("Page source saved: {}", destination); } catch (Exception e) { LOG.warn("Failed to capture page source: {}", e.getMessage()); @@ -142,7 +143,7 @@ static void captureBrowserLogs(WebDriver driver, String filePrefix) { } Path destination = getOutputPath(filePrefix + "-console.log"); - Files.write(destination, content.toString().getBytes("UTF-8")); + Files.write(destination, content.toString().getBytes(StandardCharsets.UTF_8)); LOG.info("Browser logs saved: {}", destination); } catch (Exception e) { LOG.warn("Failed to capture browser logs: {} (this may be expected if logging prefs are not supported)", e.getMessage()); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java index 895570c85..180d0a146 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java @@ -78,7 +78,7 @@ void StringHelper_convertToMultiValueMap() { assertNotNull(convertedInternalMap, "Converted map should not be null"); assertNotNull(methodReturnedMap, "Method returned map should not be null"); - assertEquals(methodReturnedMap.size(), convertedInternalMap.size(), "Maps should have the same size"); + assertEquals(convertedInternalMap.size(), methodReturnedMap.size(), "Maps should have the same size"); for (String key : convertedInternalMap.keySet()) { assertTrue(methodReturnedMap.containsKey(key), "Method returned map should contain key: " + key); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java index cccd0d472..31a953e29 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java @@ -51,7 +51,7 @@ void escapeXMLElementDataTest() { String XML_ESCAPED_DATA = "o_!as & a34~'fe<> " a1"; assertEquals(XML_ESCAPED_DATA, WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE)); - assertEquals(WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE), - StringEscapeUtils.escapeXml10(DATA_TO_ESCAPE)); + assertEquals(StringEscapeUtils.escapeXml10(DATA_TO_ESCAPE), + WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE)); } } From 042fe5c73824761792752b4bd235c25860339c6c Mon Sep 17 00:00:00 2001 From: avdunn Date: Sun, 7 Jun 2026 16:48:42 -0700 Subject: [PATCH 11/24] TokenCache tests --- .../com/microsoft/aad/msal4j/AccountTest.java | 21 +- .../microsoft/aad/msal4j/CacheFormatTest.java | 126 +++---- .../com/microsoft/aad/msal4j/CacheTest.java | 346 +++++++++++++++++- .../com/microsoft/aad/msal4j/TestHelper.java | 8 + 4 files changed, 403 insertions(+), 98 deletions(-) diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java index 60b5f4347..05e5e99e7 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java @@ -4,32 +4,19 @@ package com.microsoft.aad.msal4j; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.IOException; -import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.Base64; -import java.util.Iterator; import java.util.Map; import java.util.Set; import static com.microsoft.aad.msal4j.Constants.POINT_DELIMITER; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class AccountTest { - String getEmptyBase64EncodedJson() { - return new String(Base64.getEncoder().encode("{}".getBytes())); - } - - String getJWTHeaderBase64EncodedJson() { - return new String(Base64.getEncoder().encode("{\"alg\": \"HS256\", \"typ\": \"JWT\"}".getBytes())); - } - - private String getTestIdToken(String environment, String tenant) throws IOException, URISyntaxException { + private static String getTestIdToken(String environment, String tenant) { String claims = "{\n" + " \"iss\": \"" + environment + "\",\n" + " \"tid\": \"" + tenant + "\"\n" + @@ -37,15 +24,15 @@ private String getTestIdToken(String environment, String tenant) throws IOExcept String encodedIdToken = new String(Base64.getEncoder().encode(claims.getBytes()), StandardCharsets.UTF_8); - encodedIdToken = getJWTHeaderBase64EncodedJson() + POINT_DELIMITER + + encodedIdToken = TestHelper.getJWTHeaderBase64EncodedJson() + POINT_DELIMITER + encodedIdToken + POINT_DELIMITER + - getEmptyBase64EncodedJson(); + TestHelper.getEmptyBase64EncodedJson(); return encodedIdToken; } @Test - void multiCloudAccount_aggregatedInGetAccountsRemoveAccountApis() throws IOException, URISyntaxException { + void multiCloudAccount_aggregatedInGetAccountsRemoveAccountApis() throws Exception { String BLACK_FORESRT_TENANT = "de_tid"; String WW_TENTANT = "tid"; String BLACK_FOREST_ENV = "login.microsoftonline.de"; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java index 35d2dbf4a..0672e91a3 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java @@ -9,7 +9,6 @@ import org.skyscreamer.jsonassert.JSONCompareResult; import org.skyscreamer.jsonassert.comparator.DefaultComparator; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -17,48 +16,43 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.IOException; import java.net.URI; -import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; import java.util.*; import static com.microsoft.aad.msal4j.Constants.POINT_DELIMITER; @ExtendWith(MockitoExtension.class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class CacheFormatTest { - String TOKEN_RESPONSE = "/token_response.json"; - String TOKEN_RESPONSE_ID_TOKEN = "/token_response_id_token.json"; + private static final String TOKEN_RESPONSE = "/token_response.json"; + private static final String TOKEN_RESPONSE_ID_TOKEN = "/token_response_id_token.json"; - String AT_CACHE_ENTITY_KEY = "/at_cache_entity_key.txt"; - String AT_CACHE_ENTITY = "/at_cache_entity.json"; + private static final String AT_CACHE_ENTITY_KEY = "/at_cache_entity_key.txt"; + private static final String AT_CACHE_ENTITY = "/at_cache_entity.json"; - String RT_CACHE_ENTITY_KEY = "/rt_cache_entity_key.txt"; - String RT_CACHE_ENTITY = "/rt_cache_entity.json"; + private static final String RT_CACHE_ENTITY_KEY = "/rt_cache_entity_key.txt"; + private static final String RT_CACHE_ENTITY = "/rt_cache_entity.json"; - String ID_TOKEN_CACHE_ENTITY_KEY = "/id_token_cache_entity_key.txt"; - String ID_TOKEN_CACHE_ENTITY = "/id_token_cache_entity.json"; + private static final String ID_TOKEN_CACHE_ENTITY_KEY = "/id_token_cache_entity_key.txt"; + private static final String ID_TOKEN_CACHE_ENTITY = "/id_token_cache_entity.json"; - String ACCOUNT_CACHE_ENTITY_KEY = "/account_cache_entity_key.txt"; - String ACCOUNT_CACHE_ENTITY = "/account_cache_entity.json"; + private static final String ACCOUNT_CACHE_ENTITY_KEY = "/account_cache_entity_key.txt"; + private static final String ACCOUNT_CACHE_ENTITY = "/account_cache_entity.json"; - String APP_METADATA_ENTITY_KEY = "/app_metadata_cache_entity_key.txt"; - String APP_METADATA_CACHE_ENTITY = "/app_metadata_cache_entity.json"; + private static final String APP_METADATA_ENTITY_KEY = "/app_metadata_cache_entity_key.txt"; + private static final String APP_METADATA_CACHE_ENTITY = "/app_metadata_cache_entity.json"; - String ID_TOKEN_PLACEHOLDER = ""; - String CACHED_AT_PLACEHOLDER = ""; - String EXPIRES_ON_PLACEHOLDER = ""; - String EXTENDED_EXPIRES_ON_PLACEHOLDER = ""; + private static final String ID_TOKEN_PLACEHOLDER = ""; + private static final String CACHED_AT_PLACEHOLDER = ""; + private static final String EXPIRES_ON_PLACEHOLDER = ""; + private static final String EXTENDED_EXPIRES_ON_PLACEHOLDER = ""; @Test - void cacheDeserializationSerializationTest() throws IOException, URISyntaxException, JSONException { + void cacheDeserializationSerializationTest() throws JSONException { ITokenCache tokenCache = new TokenCache(null); - String previouslyStoredCache = readResource("/cache_data/serialized_cache.json"); + String previouslyStoredCache = TestHelper.readResource(this.getClass(), "/cache_data/serialized_cache.json"); tokenCache.deserialize(previouslyStoredCache); @@ -67,16 +61,6 @@ void cacheDeserializationSerializationTest() throws IOException, URISyntaxExcept JSONAssert.assertEquals(previouslyStoredCache, serializedCache, JSONCompareMode.STRICT); } - String readResource(String resource) throws IOException, URISyntaxException { - return new String( - Files.readAllBytes( - Paths.get(getClass().getResource(resource).toURI()))); - } - - boolean doesResourceExist(String resource) { - return getClass().getResource(resource) != null; - } - public class DynamicTimestampsComparator extends DefaultComparator { private Map expectations = new HashMap<>(); @@ -110,21 +94,21 @@ public void compareValues(String s, Object o, Object o1, JSONCompareResult jsonC } @Test - void AADTokenCacheEntitiesFormatTest() throws JSONException, IOException, URISyntaxException { + void AADTokenCacheEntitiesFormatTest() throws Exception { tokenCacheEntitiesFormatTest("/AAD_cache_data"); } @Test - void MSATokenCacheEntitiesFormatTest() throws JSONException, IOException, URISyntaxException { + void MSATokenCacheEntitiesFormatTest() throws Exception { tokenCacheEntitiesFormatTest("/MSA_cache_data"); } @Test - void FociTokenCacheEntitiesFormatTest() throws JSONException, IOException, URISyntaxException { + void FociTokenCacheEntitiesFormatTest() throws Exception { tokenCacheEntitiesFormatTest("/Foci_cache_data"); } - public void tokenCacheEntitiesFormatTest(String folder) throws URISyntaxException, IOException, JSONException { + private void tokenCacheEntitiesFormatTest(String folder) throws Exception { String CLIENT_ID = "b6c69a37-df96-4db0-9088-2ab96e1d8215"; String AUTHORIZE_REQUEST_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; @@ -171,16 +155,16 @@ public void tokenCacheEntitiesFormatTest(String folder) throws URISyntaxExceptio } private void validateAccessTokenCacheEntity(String folder, String tokenResponse, TokenCache tokenCache) - throws IOException, URISyntaxException, JSONException { + throws JSONException { assertEquals(1, tokenCache.accessTokens.size()); String keyActual = tokenCache.accessTokens.keySet().stream().findFirst().get(); - String keyExpected = readResource(folder + AT_CACHE_ENTITY_KEY); - assertEquals(keyActual, keyExpected); + String keyExpected = TestHelper.readResource(this.getClass(), folder + AT_CACHE_ENTITY_KEY); + assertEquals(keyExpected, keyActual); String valueActual = JsonHelper.convertJsonSerializableObjectToString(tokenCache.accessTokens.get(keyActual)); - String valueExpected = readResource(folder + AT_CACHE_ENTITY); + String valueExpected = TestHelper.readResource(this.getClass(), folder + AT_CACHE_ENTITY); Map tokenResponseMap = JsonHelper.convertJsonToMap(tokenResponse); @@ -189,23 +173,23 @@ private void validateAccessTokenCacheEntity(String folder, String tokenResponse, } private void validateRefreshTokenCacheEntity(String folder, TokenCache tokenCache) - throws IOException, URISyntaxException, JSONException { + throws JSONException { assertEquals(1, tokenCache.refreshTokens.size()); String actualKey = tokenCache.refreshTokens.keySet().stream().findFirst().get(); - String keyExpected = readResource(folder + RT_CACHE_ENTITY_KEY); - assertEquals(actualKey, keyExpected); + String keyExpected = TestHelper.readResource(this.getClass(), folder + RT_CACHE_ENTITY_KEY); + assertEquals(keyExpected, actualKey); String actualValue = JsonHelper.convertJsonSerializableObjectToString(tokenCache.refreshTokens.get(actualKey)); - String valueExpected = readResource(folder + RT_CACHE_ENTITY); + String valueExpected = TestHelper.readResource(this.getClass(), folder + RT_CACHE_ENTITY); JSONAssert.assertEquals(valueExpected, actualValue, JSONCompareMode.STRICT); } public class IdTokenComparator extends DefaultComparator { private String idToken; - public IdTokenComparator(JSONCompareMode mode, String folder) throws IOException, URISyntaxException { + public IdTokenComparator(JSONCompareMode mode, String folder) { super(mode); idToken = getIdToken(folder); } @@ -215,7 +199,7 @@ public void compareValues(String s, Object o, Object o1, JSONCompareResult jsonC if (ID_TOKEN_PLACEHOLDER.equals(o.toString())) { if (!idToken.equals(o1.toString())) { - jsonCompareResult.fail("idTokens don not match "); + jsonCompareResult.fail("idTokens do not match"); return; } return; @@ -225,76 +209,68 @@ public void compareValues(String s, Object o, Object o1, JSONCompareResult jsonC } private void validateIdTokenCacheEntity(String folder, TokenCache tokenCache) - throws IOException, URISyntaxException, JSONException { + throws JSONException { assertEquals(1, tokenCache.idTokens.size()); String actualKey = tokenCache.idTokens.keySet().stream().findFirst().get(); - String keyExpected = readResource(folder + ID_TOKEN_CACHE_ENTITY_KEY); - assertEquals(actualKey, keyExpected); + String keyExpected = TestHelper.readResource(this.getClass(), folder + ID_TOKEN_CACHE_ENTITY_KEY); + assertEquals(keyExpected, actualKey); String actualValue = JsonHelper.convertJsonSerializableObjectToString(tokenCache.idTokens.get(actualKey)); - String valueExpected = readResource(folder + ID_TOKEN_CACHE_ENTITY); + String valueExpected = TestHelper.readResource(this.getClass(), folder + ID_TOKEN_CACHE_ENTITY); JSONAssert.assertEquals(valueExpected, actualValue, new IdTokenComparator(JSONCompareMode.STRICT, folder)); } private void validateAccountCacheEntity(String folder, TokenCache tokenCache) - throws IOException, URISyntaxException, JSONException { + throws JSONException { assertEquals(1, tokenCache.accounts.size()); String actualKey = tokenCache.accounts.keySet().stream().findFirst().get(); - String keyExpected = readResource(folder + ACCOUNT_CACHE_ENTITY_KEY); - assertEquals(actualKey, keyExpected); + String keyExpected = TestHelper.readResource(this.getClass(), folder + ACCOUNT_CACHE_ENTITY_KEY); + assertEquals(keyExpected, actualKey); String actualValue = JsonHelper.convertJsonSerializableObjectToString(tokenCache.accounts.get(actualKey)); - String valueExpected = readResource(folder + ACCOUNT_CACHE_ENTITY); + String valueExpected = TestHelper.readResource(this.getClass(), folder + ACCOUNT_CACHE_ENTITY); JSONAssert.assertEquals(valueExpected, actualValue, JSONCompareMode.STRICT); } private void validateAppMetadataCacheEntity(String folder, TokenCache tokenCache) - throws IOException, URISyntaxException, JSONException { + throws JSONException { - if (!doesResourceExist(folder + APP_METADATA_CACHE_ENTITY)) { + if (this.getClass().getResource(folder + APP_METADATA_CACHE_ENTITY) == null) { return; } assertEquals(1, tokenCache.appMetadata.size()); String actualKey = tokenCache.appMetadata.keySet().stream().findFirst().get(); - String keyExpected = readResource(folder + APP_METADATA_ENTITY_KEY); - assertEquals(actualKey, keyExpected); + String keyExpected = TestHelper.readResource(this.getClass(), folder + APP_METADATA_ENTITY_KEY); + assertEquals(keyExpected, actualKey); String actualValue = JsonHelper.convertJsonSerializableObjectToString(tokenCache.appMetadata.get(actualKey)); - String valueExpected = readResource(folder + APP_METADATA_CACHE_ENTITY); + String valueExpected = TestHelper.readResource(this.getClass(), folder + APP_METADATA_CACHE_ENTITY); JSONAssert.assertEquals(valueExpected, actualValue, JSONCompareMode.STRICT); } - String getEmptyBase64EncodedJson() { - return new String(Base64.getEncoder().encode("{}".getBytes())); - } - - String getJWTHeaderBase64EncodedJson() { - return new String(Base64.getEncoder().encode("{\"alg\": \"HS256\", \"typ\": \"JWT\"}".getBytes())); - } - - private String getTokenResponse(String folder) throws IOException, URISyntaxException { - String tokenResponse = readResource(folder + TOKEN_RESPONSE); + private String getTokenResponse(String folder) { + String tokenResponse = TestHelper.readResource(this.getClass(), folder + TOKEN_RESPONSE); return tokenResponse.replace(ID_TOKEN_PLACEHOLDER, getIdToken(folder)); } - private String getIdToken(String folder) throws IOException, URISyntaxException { - String tokenResponseIdToken = readResource(folder + TOKEN_RESPONSE_ID_TOKEN); + private String getIdToken(String folder) { + String tokenResponseIdToken = TestHelper.readResource(this.getClass(), folder + TOKEN_RESPONSE_ID_TOKEN); String encodedIdToken = new String(Base64.getEncoder().encode(tokenResponseIdToken.getBytes()), StandardCharsets.UTF_8); - encodedIdToken = getJWTHeaderBase64EncodedJson() + POINT_DELIMITER + + encodedIdToken = TestHelper.getJWTHeaderBase64EncodedJson() + POINT_DELIMITER + encodedIdToken + POINT_DELIMITER + - getEmptyBase64EncodedJson(); + TestHelper.getEmptyBase64EncodedJson(); return encodedIdToken; } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java index 11f0d1c07..a417b5c2d 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java @@ -6,15 +6,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.net.URL; import java.util.Collections; import java.util.HashMap; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -60,8 +57,8 @@ void cacheLookup_MixAccountBasedAndAssertionBasedSilentFlows() throws Exception responseParameters.put("id_token", TestHelper.createIdToken(new HashMap<>())); when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(responseParameters))); - OnBehalfOfParameters onBehalfOfParametersarameters = OnBehalfOfParameters.builder(Collections.singleton("someOtherScopes"), new UserAssertion(TestHelper.signedAssertion)).build(); - IAuthenticationResult resultWithAccount = cca.acquireToken(onBehalfOfParametersarameters).get(); + OnBehalfOfParameters onBehalfOfParameters = OnBehalfOfParameters.builder(Collections.singleton("someOtherScopes"), new UserAssertion(TestHelper.signedAssertion)).build(); + IAuthenticationResult resultWithAccount = cca.acquireToken(onBehalfOfParameters).get(); //Ensure there are now two tokens in the cache, and the result has an account assertEquals(2, cca.tokenCache.accessTokens.size()); @@ -326,4 +323,341 @@ void getAccounts_ReturnsCorrectAccounts() { } } } + + // --- Deserialize edge cases --- + + @Test + void deserialize_NullData_NoOp() { + // Add some data first to verify it's not cleared + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId("existing"); + account.environment("login.microsoftonline.com"); + tokenCache.accounts.put(account.getKey(), account); + + tokenCache.deserialize(null); + + // Existing data should still be there + assertEquals(1, tokenCache.accounts.size()); + } + + @Test + void deserialize_BlankData_NoOp() { + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId("existing"); + account.environment("login.microsoftonline.com"); + tokenCache.accounts.put(account.getKey(), account); + + tokenCache.deserialize(""); + assertEquals(1, tokenCache.accounts.size()); + + tokenCache.deserialize(" "); + assertEquals(1, tokenCache.accounts.size()); + } + + // --- CacheAspect lifecycle tests --- + + @Test + void cacheAccessAspect_CalledDuringGetAccounts() { + ITokenCacheAccessAspect aspect = mock(ITokenCacheAccessAspect.class); + TokenCache cache = new TokenCache(aspect); + + cache.getAccounts("client-id"); + + verify(aspect, times(1)).beforeCacheAccess(any(ITokenCacheAccessContext.class)); + verify(aspect, times(1)).afterCacheAccess(any(ITokenCacheAccessContext.class)); + } + + @Test + void cacheAccessAspect_CalledDuringRemoveAccount() { + ITokenCacheAccessAspect aspect = mock(ITokenCacheAccessAspect.class); + TokenCache cache = new TokenCache(aspect); + + Account account = new Account("home-id", "login.microsoftonline.com", "user@example.com", null); + cache.removeAccount("client-id", account); + + verify(aspect, times(1)).beforeCacheAccess(any(ITokenCacheAccessContext.class)); + verify(aspect, times(1)).afterCacheAccess(any(ITokenCacheAccessContext.class)); + } + + @Test + void cacheAccessAspect_NotCalledWhenNull() { + // Default constructor — no aspect + TokenCache cache = new TokenCache(); + + // Should not throw even without an aspect + cache.getAccounts("client-id"); + cache.removeAccount("client-id", new Account("id", "env", "user", null)); + } + + @Test + void cacheAccessAspect_DeserializesBeforeAccess() { + // Simulate a persistence aspect that populates cache on beforeCacheAccess + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId("aspect-home-id"); + account.environment("login.microsoftonline.com"); + account.realm("tenant"); + account.username("aspect-user@example.com"); + tokenCache.accounts.put(account.getKey(), account); + String serializedWithAccount = tokenCache.serialize(); + + ITokenCacheAccessAspect persistenceAspect = new ITokenCacheAccessAspect() { + @Override + public void beforeCacheAccess(ITokenCacheAccessContext context) { + context.tokenCache().deserialize(serializedWithAccount); + } + + @Override + public void afterCacheAccess(ITokenCacheAccessContext context) { + // no-op + } + }; + + // Create a fresh cache with the persistence aspect + TokenCache freshCache = new TokenCache(persistenceAspect); + assertTrue(freshCache.accounts.isEmpty()); + + // getAccounts should trigger beforeCacheAccess, which populates the cache + Set accounts = freshCache.getAccounts("client-id"); + assertEquals(1, accounts.size()); + assertEquals("aspect-user@example.com", accounts.iterator().next().username()); + } + + // --- Family RT / FOCI cache lookup tests --- + + @Test + void getCachedAuthenticationResult_FamilyApp_PrefersAnyFamilyRT() throws Exception { + String homeAccountId = "home-id"; + String environment = "login.microsoftonline.com"; + String clientId = "family-client"; + String realm = "tenant-id"; + + // Add account + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId(homeAccountId); + account.environment(environment); + account.realm(realm); + tokenCache.accounts.put(account.getKey(), account); + + // Add a regular (non-family) refresh token for this client + RefreshTokenCacheEntity regularRt = new RefreshTokenCacheEntity(); + regularRt.homeAccountId(homeAccountId); + regularRt.environment(environment); + regularRt.clientId(clientId); + regularRt.credentialType(CredentialTypeEnum.REFRESH_TOKEN.value()); + regularRt.secret("regular-rt-secret"); + tokenCache.refreshTokens.put(regularRt.getKey(), regularRt); + + // Add a family refresh token (different client, but family) + RefreshTokenCacheEntity familyRt = new RefreshTokenCacheEntity(); + familyRt.homeAccountId(homeAccountId); + familyRt.environment(environment); + familyRt.clientId("other-family-client"); + familyRt.credentialType(CredentialTypeEnum.REFRESH_TOKEN.value()); + familyRt.secret("family-rt-secret"); + familyRt.family_id("1"); + tokenCache.refreshTokens.put(familyRt.getKey(), familyRt); + + // Add app metadata marking this client as a family app + AppMetadataCacheEntity appMeta = new AppMetadataCacheEntity(); + appMeta.clientId(clientId); + appMeta.environment(environment); + appMeta.familyId("1"); + tokenCache.appMetadata.put(appMeta.getKey(), appMeta); + + // Look up cached result — family app should prefer family RT + IAccount iAccount = new Account(homeAccountId, environment, "user", null); + Authority authority = new AADAuthority(new URL("https://login.microsoftonline.com/tenant-id/")); + + AuthenticationResult result = tokenCache.getCachedAuthenticationResult( + iAccount, authority, Collections.singleton("scope"), clientId); + + assertEquals("family-rt-secret", result.refreshToken()); + } + + @Test + void getCachedAuthenticationResult_NonFamilyApp_FallsBackToFamilyRT() throws Exception { + String homeAccountId = "home-id"; + String environment = "login.microsoftonline.com"; + String clientId = "non-family-client"; + String realm = "tenant-id"; + + // Add account + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId(homeAccountId); + account.environment(environment); + account.realm(realm); + tokenCache.accounts.put(account.getKey(), account); + + // No regular RT for this client — only a family RT from another client + RefreshTokenCacheEntity familyRt = new RefreshTokenCacheEntity(); + familyRt.homeAccountId(homeAccountId); + familyRt.environment(environment); + familyRt.clientId("family-client"); + familyRt.credentialType(CredentialTypeEnum.REFRESH_TOKEN.value()); + familyRt.secret("family-rt-fallback"); + familyRt.family_id("1"); + tokenCache.refreshTokens.put(familyRt.getKey(), familyRt); + + // No app metadata for non-family-client (it's not a known family member) + IAccount iAccount = new Account(homeAccountId, environment, "user", null); + Authority authority = new AADAuthority(new URL("https://login.microsoftonline.com/tenant-id/")); + + AuthenticationResult result = tokenCache.getCachedAuthenticationResult( + iAccount, authority, Collections.singleton("scope"), clientId); + + // Non-family app has no regular RT, so should fall back to family RT + assertEquals("family-rt-fallback", result.refreshToken()); + } + + @Test + void getCachedAuthenticationResult_WithRefreshOn_IncludedInResult() throws Exception { + String homeAccountId = "home-id"; + String environment = "login.microsoftonline.com"; + String clientId = "client-id"; + String realm = "tenant-id"; + + // Add account + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId(homeAccountId); + account.environment(environment); + account.realm(realm); + tokenCache.accounts.put(account.getKey(), account); + + // Add access token with refreshOn + long futureExpiry = (System.currentTimeMillis() / 1000) + 3600; + long refreshOn = (System.currentTimeMillis() / 1000) + 1800; + + AccessTokenCacheEntity at = new AccessTokenCacheEntity(); + at.homeAccountId(homeAccountId); + at.environment(environment); + at.clientId(clientId); + at.credentialType(CredentialTypeEnum.ACCESS_TOKEN.value()); + at.realm(realm); + at.target("scope"); + at.secret("access-token-secret"); + at.cachedAt(Long.toString(System.currentTimeMillis() / 1000)); + at.expiresOn(Long.toString(futureExpiry)); + at.refreshOn(Long.toString(refreshOn)); + tokenCache.accessTokens.put(at.getKey(), at); + + IAccount iAccount = new Account(homeAccountId, environment, "user", null); + Authority authority = new AADAuthority(new URL("https://login.microsoftonline.com/tenant-id/")); + + AuthenticationResult result = tokenCache.getCachedAuthenticationResult( + iAccount, authority, Collections.singleton("scope"), clientId); + + assertEquals("access-token-secret", result.accessToken()); + assertEquals(refreshOn, result.refreshOn()); + } + + @Test + void getCachedAuthenticationResult_NoAccessToken_FallsBackToAuthorityHost() throws Exception { + String homeAccountId = "home-id"; + String environment = "login.microsoftonline.com"; + String clientId = "client-id"; + String realm = "tenant-id"; + + // Add account but no access token + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId(homeAccountId); + account.environment(environment); + account.realm(realm); + tokenCache.accounts.put(account.getKey(), account); + + IAccount iAccount = new Account(homeAccountId, environment, "user", null); + Authority authority = new AADAuthority(new URL("https://login.microsoftonline.com/tenant-id/")); + + AuthenticationResult result = tokenCache.getCachedAuthenticationResult( + iAccount, authority, Collections.singleton("scope"), clientId); + + // No AT in cache, so environment should come from authority host + assertNull(result.accessToken()); + assertEquals("login.microsoftonline.com", result.environment()); + } + + @Test + void getCachedAuthenticationResult_AssertionBased_WithIdTokenAndRefreshToken() throws Exception { + String environment = "login.microsoftonline.com"; + String clientId = "client-id"; + String realm = "tenant-id"; + + // Create the assertion and get its computed hash + UserAssertion assertion = new UserAssertion(TestHelper.signedAssertion); + String userAssertionHash = assertion.getAssertionHash(); + + // Add access token with assertion hash + long futureExpiry = (System.currentTimeMillis() / 1000) + 3600; + AccessTokenCacheEntity at = new AccessTokenCacheEntity(); + at.environment(environment); + at.clientId(clientId); + at.credentialType(CredentialTypeEnum.ACCESS_TOKEN.value()); + at.realm(realm); + at.target("scope"); + at.secret("at-with-assertion"); + at.cachedAt(Long.toString(System.currentTimeMillis() / 1000)); + at.expiresOn(Long.toString(futureExpiry)); + at.userAssertionHash(userAssertionHash); + tokenCache.accessTokens.put(at.getKey(), at); + + // Add ID token with assertion hash + IdTokenCacheEntity idToken = new IdTokenCacheEntity(); + idToken.environment(environment); + idToken.clientId(clientId); + idToken.credentialType(CredentialTypeEnum.ID_TOKEN.value()); + idToken.realm(realm); + idToken.secret("id-token-secret"); + idToken.userAssertionHash(userAssertionHash); + tokenCache.idTokens.put(idToken.getKey(), idToken); + + // Add refresh token with assertion hash + RefreshTokenCacheEntity rt = new RefreshTokenCacheEntity(); + rt.environment(environment); + rt.clientId(clientId); + rt.credentialType(CredentialTypeEnum.REFRESH_TOKEN.value()); + rt.secret("rt-with-assertion"); + rt.userAssertionHash(userAssertionHash); + tokenCache.refreshTokens.put(rt.getKey(), rt); + + Authority authority = new AADAuthority(new URL("https://login.microsoftonline.com/tenant-id/")); + + AuthenticationResult result = tokenCache.getCachedAuthenticationResult( + authority, Collections.singleton("scope"), clientId, assertion); + + assertEquals("at-with-assertion", result.accessToken()); + assertEquals("id-token-secret", result.idToken()); + assertEquals("rt-with-assertion", result.refreshToken()); + } + + // --- Serialize with merge behavior tests --- + + @Test + void serialize_WithSnapshot_MergesWithExistingData() { + // Set up a cache with initial data, then serialize to create a snapshot + AccountCacheEntity account1 = new AccountCacheEntity(); + account1.homeAccountId("account-1"); + account1.environment("login.microsoftonline.com"); + account1.realm("tenant-1"); + tokenCache.accounts.put(account1.getKey(), account1); + + // Deserialize from an existing snapshot to set serializedCachedSnapshot + String snapshot = tokenCache.serialize(); + + TokenCache cacheWithSnapshot = new TokenCache(); + cacheWithSnapshot.deserialize(snapshot); + + // Add new data to the cache (simulating new token acquisition) + AccountCacheEntity account2 = new AccountCacheEntity(); + account2.homeAccountId("account-2"); + account2.environment("login.microsoftonline.com"); + account2.realm("tenant-2"); + cacheWithSnapshot.accounts.put(account2.getKey(), account2); + + // Serialize — should merge new data with snapshot + String mergedSerialized = cacheWithSnapshot.serialize(); + + // Verify both accounts are in the merged result + TokenCache verifyCache = new TokenCache(); + verifyCache.deserialize(mergedSerialized); + assertEquals(2, verifyCache.accounts.size()); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java index 4112dcebd..6a46d2b7a 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java @@ -74,6 +74,14 @@ class TestHelper { "\"tid\": \"%s\"," + "\"ver\": \"2.0\"}"; + static String getEmptyBase64EncodedJson() { + return new String(Base64.getEncoder().encode("{}".getBytes())); + } + + static String getJWTHeaderBase64EncodedJson() { + return new String(Base64.getEncoder().encode("{\"alg\": \"HS256\", \"typ\": \"JWT\"}".getBytes())); + } + static X509Certificate x509Cert = getX509Cert(); static PrivateKey privateKey = getPrivateKey(); From 7634b5e2be5094f1e11f08e12c2360ede568e39b Mon Sep 17 00:00:00 2001 From: avdunn Date: Sun, 7 Jun 2026 18:34:31 -0700 Subject: [PATCH 12/24] AuthenticationResult tests --- .../aad/msal4j/AuthenticationResultTest.java | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultTest.java diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultTest.java new file mode 100644 index 000000000..22ab16806 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultTest.java @@ -0,0 +1,468 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Date; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class AuthenticationResultTest { + + // Shared test constants + private static final String ACCESS_TOKEN = "test-access-token"; + private static final long EXPIRES_ON = 1735689600L; + private static final long EXT_EXPIRES_ON = 1735693200L; + private static final String REFRESH_TOKEN = "test-refresh-token"; + private static final Long REFRESH_ON = 1735686000L; + private static final String FAMILY_ID = "1"; + private static final String ENVIRONMENT = "login.microsoftonline.com"; + private static final String SCOPES = "user.read openid"; + + // Metadata is shared across equals/hashCode tests because AuthenticationResultMetadata + // does not implement equals(), so two separately-constructed instances will fail + // reference equality even if all fields match. + private static final AuthenticationResultMetadata SHARED_METADATA = + AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.IDENTITY_PROVIDER) + .refreshOn(REFRESH_ON) + .cacheRefreshReason(CacheRefreshReason.NOT_APPLICABLE) + .build(); + + private static AccountCacheEntity buildAccountCacheEntity() { + AccountCacheEntity entity = new AccountCacheEntity(); + entity.homeAccountId = "uid.utid"; + entity.environment = ENVIRONMENT; + entity.realm = "test-tenant"; + entity.localAccountId = "local-oid"; + entity.username = "user@example.com"; + entity.authorityType = AccountCacheEntity.MSSTS_ACCOUNT_TYPE; + return entity; + } + + /** + * Builds a result with no idToken (and thus no idTokenObject or tenantProfile), which + * is needed for equals/hashCode tests because IdToken and TenantProfile do not implement + * equals(). This ensures equals() comparisons are based on value equality of all fields. + */ + private static AuthenticationResult buildBaselineResult() { + return AuthenticationResult.builder() + .accessToken(ACCESS_TOKEN) + .expiresOn(EXPIRES_ON) + .extExpiresOn(EXT_EXPIRES_ON) + .refreshToken(REFRESH_TOKEN) + .refreshOn(REFRESH_ON) + .familyId(FAMILY_ID) + .accountCacheEntity(buildAccountCacheEntity()) + .environment(ENVIRONMENT) + .scopes(SCOPES) + .metadata(SHARED_METADATA) + .isPopAuthorization(false) + .build(); + } + + // ========== Builder and Getter Tests ========== + + @Test + void build_AllFieldsSet_GettersReturnExpectedValues() { + AccountCacheEntity accountEntity = buildAccountCacheEntity(); + AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.CACHE) + .refreshOn(REFRESH_ON) + .cacheRefreshReason(CacheRefreshReason.PROACTIVE_REFRESH) + .build(); + + AuthenticationResult result = AuthenticationResult.builder() + .accessToken(ACCESS_TOKEN) + .expiresOn(EXPIRES_ON) + .extExpiresOn(EXT_EXPIRES_ON) + .refreshToken(REFRESH_TOKEN) + .refreshOn(REFRESH_ON) + .familyId(FAMILY_ID) + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(accountEntity) + .environment(ENVIRONMENT) + .scopes(SCOPES) + .metadata(metadata) + .isPopAuthorization(true) + .build(); + + assertEquals(ACCESS_TOKEN, result.accessToken()); + assertEquals(EXPIRES_ON, result.expiresOn()); + assertEquals(EXT_EXPIRES_ON, result.extExpiresOn()); + assertEquals(REFRESH_TOKEN, result.refreshToken()); + assertEquals(REFRESH_ON, result.refreshOn()); + assertEquals(FAMILY_ID, result.familyId()); + assertEquals(TestHelper.ENCODED_JWT, result.idToken()); + assertEquals(accountEntity, result.accountCacheEntity()); + assertEquals(ENVIRONMENT, result.environment()); + assertEquals(SCOPES, result.scopes()); + assertSame(metadata, result.metadata()); + assertTrue(result.isPopAuthorization()); + } + + @Test + void build_MinimalFields_NullableFieldsReturnNull() { + AuthenticationResult result = AuthenticationResult.builder() + .expiresOn(EXPIRES_ON) + .build(); + + assertNull(result.accessToken()); + assertEquals(EXPIRES_ON, result.expiresOn()); + assertEquals(0, result.extExpiresOn()); + assertNull(result.refreshToken()); + assertNull(result.refreshOn()); + assertNull(result.familyId()); + assertNull(result.idToken()); + assertNull(result.accountCacheEntity()); + assertNull(result.environment()); + assertNull(result.scopes()); + assertNull(result.isPopAuthorization()); + assertNull(result.account()); + } + + @Test + void build_NullMetadata_DefaultsToEmptyMetadata() { + AuthenticationResult result = AuthenticationResult.builder() + .metadata(null) + .build(); + + assertNotNull(result.metadata()); + assertNull(result.metadata().tokenSource()); + assertEquals(CacheRefreshReason.NOT_APPLICABLE, result.metadata().cacheRefreshReason()); + } + + // ========== Derived Field Tests ========== + + @Test + void build_WithIdToken_IdTokenObjectIsNull_DueToInitOrder() { + // Documents a known issue: field initializers run before the constructor body, + // so idTokenObject = getIdTokenObj() executes when this.idToken is still null. + // This means idTokenObject() always returns null regardless of the idToken value. + AuthenticationResult result = AuthenticationResult.builder() + .idToken(TestHelper.ENCODED_JWT) + .build(); + + assertNull(result.idTokenObject(), + "idTokenObject is always null due to field initialization order"); + // The idToken string itself is stored correctly + assertEquals(TestHelper.ENCODED_JWT, result.idToken()); + } + + @Test + void build_WithBlankIdToken_IdTokenObjectIsNull() { + AuthenticationResult result = AuthenticationResult.builder() + .idToken("") + .build(); + + assertNull(result.idTokenObject()); + } + + @Test + void build_WithAccountCacheEntity_PublicGetterRecomputesAccount() { + // The public account() getter calls getAccount() each time, so it works correctly + // despite the field initialization order bug (the private 'account' field is always null). + AccountCacheEntity entity = buildAccountCacheEntity(); + + AuthenticationResult result = AuthenticationResult.builder() + .accountCacheEntity(entity) + .build(); + + IAccount account = result.account(); + assertNotNull(account); + assertEquals("uid.utid", account.homeAccountId()); + assertEquals(ENVIRONMENT, account.environment()); + assertEquals("user@example.com", account.username()); + } + + @Test + void build_WithNullAccountCacheEntity_AccountIsNull() { + AuthenticationResult result = AuthenticationResult.builder() + .accountCacheEntity(null) + .build(); + + assertNull(result.account()); + } + + @Test + void build_WithIdTokenAndAccount_TenantProfileIsNull_DueToInitOrder() { + // Documents same initialization order issue: tenantProfile = getTenantProfile() + // runs before this.idToken is set, so StringHelper.isBlank(idToken) returns true + // and tenantProfile is always null. + AccountCacheEntity entity = buildAccountCacheEntity(); + + AuthenticationResult result = AuthenticationResult.builder() + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(entity) + .build(); + + assertNull(result.tenantProfile(), + "tenantProfile is always null due to field initialization order"); + } + + @Test + void build_ExpiresOn_ExpiresOnDateConvertedCorrectly() { + AuthenticationResult result = AuthenticationResult.builder() + .expiresOn(EXPIRES_ON) + .build(); + + Date expectedDate = new Date(EXPIRES_ON * 1000); + assertEquals(expectedDate, result.expiresOnDate()); + } + + @Test + void build_WithIdTokenButNullAccount_DoesNotThrowNPE_DueToInitOrder() { + // If the field initialization order bug were fixed, this scenario would throw NPE: + // getTenantProfile() calls getAccount().environment(), and getAccount() returns null + // when accountCacheEntity is null. However, since idToken is null at init time, + // getTenantProfile() returns null before reaching the getAccount() call. + AuthenticationResult result = AuthenticationResult.builder() + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(null) + .build(); + + assertNull(result.tenantProfile()); + assertEquals(TestHelper.ENCODED_JWT, result.idToken()); + } + + // ========== equals() Tests ========== + + @Test + void equals_SameInstance_ReturnsTrue() { + AuthenticationResult result = buildBaselineResult(); + assertEquals(result, result); + } + + @Test + void equals_WrongType_ReturnsFalse() { + AuthenticationResult result = buildBaselineResult(); + assertFalse(result.equals("not an AuthenticationResult")); + } + + @Test + void equals_Null_ReturnsFalse() { + AuthenticationResult result = buildBaselineResult(); + assertFalse(result.equals(null)); + } + + @Test + void equals_IdenticalFields_NoIdToken_ReturnsTrue() { + // Two independently-constructed results with identical fields and shared metadata + // should be equal when idToken is blank (avoiding IdToken/TenantProfile reference + // equality issues). This is the only scenario where equals() works for separately + // constructed instances, because AuthenticationResultMetadata also lacks equals(). + AuthenticationResult result1 = buildBaselineResult(); + AuthenticationResult result2 = buildBaselineResult(); + assertEquals(result1, result2); + } + + /** + * Parameterized test that verifies each field in equals() by varying one field at a time + * from the baseline. Uses blank idToken and shared metadata to ensure reference equality + * issues in IdToken/TenantProfile/Metadata don't interfere. + * + * Each argument set contains: field name (for display), and a result with that field changed. + */ + @ParameterizedTest(name = "equals returns false when {0} differs") + @MethodSource("differentFieldProvider") + void equals_SingleFieldDiffers_ReturnsFalse(String fieldName, AuthenticationResult modified) { + AuthenticationResult baseline = buildBaselineResult(); + assertNotEquals(baseline, modified, "Results should differ when " + fieldName + " differs"); + } + + private static Stream differentFieldProvider() { + AccountCacheEntity differentAccount = buildAccountCacheEntity(); + differentAccount.homeAccountId = "different.account"; + + return Stream.of( + Arguments.of("accessToken", buildWithOverride().accessToken("different-token").build()), + Arguments.of("expiresOn", buildWithOverride().expiresOn(999L).build()), + Arguments.of("extExpiresOn", buildWithOverride().extExpiresOn(999L).build()), + Arguments.of("refreshToken", buildWithOverride().refreshToken("different-rt").build()), + Arguments.of("refreshOn", buildWithOverride().refreshOn(999L).build()), + Arguments.of("familyId", buildWithOverride().familyId("2").build()), + Arguments.of("environment", buildWithOverride().environment("different.env").build()), + Arguments.of("scopes", buildWithOverride().scopes("different.scope").build()), + Arguments.of("isPopAuthorization", buildWithOverride().isPopAuthorization(true).build()), + Arguments.of("accountCacheEntity", buildWithOverride().accountCacheEntity(differentAccount).build()) + ); + } + + /** + * Returns a builder pre-populated with baseline values, ready for one field to be overridden. + */ + private static AuthenticationResult.AuthenticationResultBuilder buildWithOverride() { + return AuthenticationResult.builder() + .accessToken(ACCESS_TOKEN) + .expiresOn(EXPIRES_ON) + .extExpiresOn(EXT_EXPIRES_ON) + .refreshToken(REFRESH_TOKEN) + .refreshOn(REFRESH_ON) + .familyId(FAMILY_ID) + .accountCacheEntity(buildAccountCacheEntity()) + .environment(ENVIRONMENT) + .scopes(SCOPES) + .metadata(SHARED_METADATA) + .isPopAuthorization(false); + } + + @Test + void equals_IdTokenSetOnBoth_ReturnsTrue_BecauseIdTokenObjectAlwaysNull() { + // Despite IdToken lacking equals(), this comparison passes because the field + // initialization order bug means idTokenObject is always null for both results. + // The idToken string comparison (line 238) passes, and the idTokenObject comparison + // (line 239) passes because both are null. + AccountCacheEntity entity = buildAccountCacheEntity(); + AuthenticationResult result1 = AuthenticationResult.builder() + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(entity) + .metadata(SHARED_METADATA) + .build(); + AuthenticationResult result2 = AuthenticationResult.builder() + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(entity) + .metadata(SHARED_METADATA) + .build(); + + assertEquals(result1, result2, + "Results with same idToken are equal because idTokenObject is always null"); + } + + @Test + void equals_DifferentMetadataInstances_ReturnsFalse() { + // Documents that AuthenticationResultMetadata also lacks equals(), so two results + // with equivalent but separately-constructed metadata instances will not be equal. + AuthenticationResultMetadata meta1 = AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.CACHE).build(); + AuthenticationResultMetadata meta2 = AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.CACHE).build(); + + AuthenticationResult result1 = AuthenticationResult.builder() + .expiresOn(EXPIRES_ON) + .metadata(meta1) + .build(); + AuthenticationResult result2 = AuthenticationResult.builder() + .expiresOn(EXPIRES_ON) + .metadata(meta2) + .build(); + + assertNotEquals(result1, result2, + "Two results with equivalent metadata instances are not equal because AuthenticationResultMetadata lacks equals()"); + } + + @Test + void equals_NullVsNonNullRefreshOn_ReturnsFalse() { + AuthenticationResult withRefreshOn = buildBaselineResult(); + AuthenticationResult withoutRefreshOn = buildWithOverride().refreshOn(null).build(); + + assertNotEquals(withRefreshOn, withoutRefreshOn); + } + + // ========== hashCode() Tests ========== + + @Test + void hashCode_EqualObjects_ReturnSameHash() { + AuthenticationResult result1 = buildBaselineResult(); + AuthenticationResult result2 = buildBaselineResult(); + + // Verify the contract: equal objects must have equal hash codes + assertEquals(result1, result2, "Precondition: results must be equal"); + assertEquals(result1.hashCode(), result2.hashCode()); + } + + @Test + void hashCode_AllNullableFieldsNull_DoesNotThrow() { + AuthenticationResult result = AuthenticationResult.builder().build(); + + // Should not throw NPE — all null branches use the constant 43 + int hash = result.hashCode(); + assertNotEquals(0, hash, "Hash should be computed even with all-null fields"); + } + + @Test + void hashCode_AllFieldsSet_DoesNotThrow() { + AuthenticationResult result = AuthenticationResult.builder() + .accessToken(ACCESS_TOKEN) + .expiresOn(EXPIRES_ON) + .extExpiresOn(EXT_EXPIRES_ON) + .refreshToken(REFRESH_TOKEN) + .refreshOn(REFRESH_ON) + .familyId(FAMILY_ID) + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(buildAccountCacheEntity()) + .environment(ENVIRONMENT) + .scopes(SCOPES) + .metadata(SHARED_METADATA) + .isPopAuthorization(true) + .build(); + + int hash = result.hashCode(); + assertNotEquals(0, hash); + } + + // ========== AuthenticationResultMetadata Tests ========== + + @Test + void metadata_BuilderDefaults_CacheRefreshReasonNotApplicable() { + AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder().build(); + + assertNull(metadata.tokenSource()); + assertNull(metadata.refreshOn()); + // CacheRefreshReason defaults to NOT_APPLICABLE in the constructor + assertEquals(CacheRefreshReason.NOT_APPLICABLE, metadata.cacheRefreshReason()); + } + + @Test + void metadata_AllFieldsSet_GettersReturnExpectedValues() { + AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.CACHE) + .refreshOn(1735686000L) + .cacheRefreshReason(CacheRefreshReason.EXPIRED) + .build(); + + assertEquals(TokenSource.CACHE, metadata.tokenSource()); + assertEquals(1735686000L, metadata.refreshOn()); + assertEquals(CacheRefreshReason.EXPIRED, metadata.cacheRefreshReason()); + } + + @Test + void metadata_NullCacheRefreshReason_DefaultsToNotApplicable() { + AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder() + .cacheRefreshReason(null) + .build(); + + assertEquals(CacheRefreshReason.NOT_APPLICABLE, metadata.cacheRefreshReason()); + } + + @Test + void metadata_Setters_UpdateValues() { + AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder().build(); + + metadata.tokenSource(TokenSource.IDENTITY_PROVIDER); + metadata.refreshOn(5000L); + metadata.cacheRefreshReason(CacheRefreshReason.PROACTIVE_REFRESH); + + assertEquals(TokenSource.IDENTITY_PROVIDER, metadata.tokenSource()); + assertEquals(5000L, metadata.refreshOn()); + assertEquals(CacheRefreshReason.PROACTIVE_REFRESH, metadata.cacheRefreshReason()); + } + + @Test + void metadata_ToString_ContainsFieldValues() { + String toString = AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.CACHE) + .refreshOn(100L) + .cacheRefreshReason(CacheRefreshReason.EXPIRED) + .toString(); + + assertTrue(toString.contains("CACHE")); + assertTrue(toString.contains("100")); + assertTrue(toString.contains("EXPIRED")); + } +} From ade7b5955ce11745076c57618931e7edf5d19c6e Mon Sep 17 00:00:00 2001 From: avdunn Date: Sun, 7 Jun 2026 18:35:36 -0700 Subject: [PATCH 13/24] Application tests --- .../aad/msal4j/ApplicationBuilderTest.java | 490 ++++++++++++++++++ .../aad/msal4j/ClientCredentialTest.java | 6 +- 2 files changed, 492 insertions(+), 4 deletions(-) create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java new file mode 100644 index 000000000..97acee59e --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java @@ -0,0 +1,490 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import javax.net.ssl.SSLSocketFactory; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ApplicationBuilderTest { + + private static final String CLIENT_ID = "test-client-id"; + private static final String DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common/"; + + // ========== PublicClientApplication Tests ========== + + @Test + void publicClientApplication_MinimalBuild_HasExpectedDefaults() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID).build(); + + assertEquals(CLIENT_ID, pca.clientId()); + assertEquals(DEFAULT_AUTHORITY, pca.authority()); + assertTrue(pca.validateAuthority()); + assertTrue(pca.instanceDiscovery()); + assertFalse(pca.autoDetectRegion()); + assertNull(pca.azureRegion()); + assertNull(pca.applicationName()); + assertNull(pca.applicationVersion()); + assertNull(pca.clientCapabilities()); + assertNull(pca.aadAadInstanceDiscoveryResponse()); + assertNotNull(pca.tokenCache()); + } + + @Test + void publicClientApplication_BlankClientId_Throws() { + assertThrows(IllegalArgumentException.class, + () -> PublicClientApplication.builder("").build()); + assertThrows(IllegalArgumentException.class, + () -> PublicClientApplication.builder(" ").build()); + } + + @Test + void publicClientApplication_NullClientId_Throws() { + assertThrows(IllegalArgumentException.class, + () -> PublicClientApplication.builder(null).build()); + } + + @Test + void publicClientApplication_AadAuthority_SetsCorrectType() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .authority("https://login.microsoftonline.com/my-tenant/") + .build(); + + assertEquals("https://login.microsoftonline.com/my-tenant/", pca.authority()); + } + + @Test + void publicClientApplication_AdfsAuthority_SetsCorrectType() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .authority("https://adfs.contoso.com/adfs/") + .build(); + + assertEquals("https://adfs.contoso.com/adfs/", pca.authority()); + // ADFS authority type is set, but validateAuthority is not automatically disabled + // (unlike B2C which explicitly sets validateAuthority=false) + } + + @Test + void publicClientApplication_CiamAuthority_SetsCorrectType() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .authority("https://contoso.ciamlogin.com/contoso.onmicrosoft.com/") + .build(); + + assertEquals("https://contoso.ciamlogin.com/contoso.onmicrosoft.com/", pca.authority()); + } + + @Test + void publicClientApplication_B2cAuthority_SetsCorrectType() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .b2cAuthority("https://contoso.b2clogin.com/contoso.onmicrosoft.com/B2C_1_signIn/") + .build(); + + // B2C lowercases the authority path + assertEquals("https://contoso.b2clogin.com/contoso.onmicrosoft.com/b2c_1_signin/", pca.authority()); + assertFalse(pca.validateAuthority(), "B2C sets validateAuthority to false"); + } + + @Test + void publicClientApplication_B2cAuthorityViaRegularAuthority_Throws() { + // Using the regular authority() method with a B2C URL should throw since it's not + // AAD/ADFS/CIAM — it requires b2cAuthority() instead + assertThrows(IllegalArgumentException.class, () -> + PublicClientApplication.builder(CLIENT_ID) + .authority("https://contoso.b2clogin.com/contoso.onmicrosoft.com/B2C_1_signIn/") + .build()); + } + + @Test + void publicClientApplication_DeviceCodeWithB2C_Throws() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .b2cAuthority("https://contoso.b2clogin.com/contoso.onmicrosoft.com/B2C_1_signIn/") + .build(); + + DeviceCodeFlowParameters params = DeviceCodeFlowParameters.builder( + Collections.singleton("scope"), + deviceCode -> {}).build(); + + assertThrows(IllegalArgumentException.class, () -> pca.acquireToken(params)); + } + + @Test + void publicClientApplication_PopWithoutBroker_Interactive_Throws() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID).build(); + + InteractiveRequestParameters params = InteractiveRequestParameters.builder(new URI("http://localhost")) + .scopes(Collections.singleton("scope")) + .proofOfPossession(HttpMethod.GET, new URI("https://resource.com"), "nonce") + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> pca.acquireToken(params)); + assertTrue(ex.getMessage().contains("proofOfPossession")); + } + + @Test + void publicClientApplication_PopWithoutBroker_UsernamePassword_Throws() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID).build(); + + UserNamePasswordParameters params = UserNamePasswordParameters.builder( + Collections.singleton("scope"), "user@example.com", "password".toCharArray()) + .proofOfPossession(HttpMethod.GET, new URI("https://resource.com"), "nonce") + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> pca.acquireToken(params)); + assertTrue(ex.getMessage().contains("proofOfPossession")); + } + + @Test + void publicClientApplication_PopWithoutBroker_Silent_Throws() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID).build(); + + SilentParameters params = SilentParameters.builder(Collections.singleton("scope")) + .proofOfPossession(HttpMethod.GET, new URI("https://resource.com"), "nonce") + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, + () -> pca.acquireTokenSilently(params)); + assertTrue(ex.getMessage().contains("proofOfPossession")); + } + + @Test + void publicClientApplication_BrokerEnabled_BuildsSuccessfully() { + IBroker mockBroker = mock(IBroker.class); + when(mockBroker.isBrokerAvailable()).thenReturn(true); + + // Exercises Builder.broker() which sets brokerEnabled = broker.isBrokerAvailable() + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .broker(mockBroker) + .build(); + + // brokerEnabled is private, so we verify the builder path was exercised + // by confirming construction succeeded and the broker's availability was checked + assertNotNull(pca); + } + + // ========== ConfidentialClientApplication Tests ========== + + @Test + void confidentialClientApplication_MinimalBuild_HasExpectedDefaults() { + IClientCredential credential = ClientCredentialFactory.createFromSecret("test-secret"); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(CLIENT_ID, credential) + .build(); + + assertEquals(CLIENT_ID, cca.clientId()); + assertEquals(DEFAULT_AUTHORITY, cca.authority()); + assertTrue(cca.sendX5c(), "sendX5c defaults to true"); + assertNotNull(cca.tokenCache()); + } + + @Test + void confidentialClientApplication_NullCredential_Throws() { + assertThrows(IllegalArgumentException.class, + () -> ConfidentialClientApplication.builder(CLIENT_ID, null)); + } + + @Test + void confidentialClientApplication_SendX5c_SetToFalse() { + IClientCredential credential = ClientCredentialFactory.createFromSecret("test-secret"); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(CLIENT_ID, credential) + .sendX5c(false) + .build(); + + assertFalse(cca.sendX5c()); + } + + @Test + void confidentialClientApplication_AppTokenProvider_Valid() { + IClientCredential credential = ClientCredentialFactory.createFromSecret("test-secret"); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(CLIENT_ID, credential) + .appTokenProvider(params -> { + TokenProviderResult result = new TokenProviderResult(); + result.setAccessToken("token"); + result.setExpiresInSeconds(3600); + return CompletableFuture.completedFuture(result); + }) + .build(); + + assertNotNull(cca.appTokenProvider); + } + + @Test + void confidentialClientApplication_AppTokenProvider_Null_Throws() { + IClientCredential credential = ClientCredentialFactory.createFromSecret("test-secret"); + + assertThrows(NullPointerException.class, () -> + ConfidentialClientApplication.builder(CLIENT_ID, credential) + .appTokenProvider(null)); + } + + // ========== ManagedIdentityApplication Tests ========== + + @Test + void managedIdentityApplication_SystemAssigned_Build() { + ManagedIdentityApplication mia = ManagedIdentityApplication.builder( + ManagedIdentityId.systemAssigned()).build(); + + assertNotNull(mia.getManagedIdentityId()); + assertEquals(ManagedIdentityIdType.SYSTEM_ASSIGNED, mia.getManagedIdentityId().getIdType()); + } + + @Test + void managedIdentityApplication_GetSharedTokenCache_ReturnsCache() { + assertNotNull(ManagedIdentityApplication.getSharedTokenCache()); + } + + @Test + void managedIdentityApplication_GetEnvironmentVariables_ReturnsValue() { + // getEnvironmentVariables() returns whatever was last set via setEnvironmentVariables() + IEnvironmentVariables original = ManagedIdentityApplication.getEnvironmentVariables(); + try { + IEnvironmentVariables mockEnv = mock(IEnvironmentVariables.class); + ManagedIdentityApplication.setEnvironmentVariables(mockEnv); + assertSame(mockEnv, ManagedIdentityApplication.getEnvironmentVariables()); + } finally { + ManagedIdentityApplication.setEnvironmentVariables(original); + } + } + + @Test + void managedIdentityApplication_DeprecatedResource_DoesNotThrow() { + // deprecated resource() is a no-op, should not throw + ManagedIdentityApplication mia = ManagedIdentityApplication.builder( + ManagedIdentityId.systemAssigned()) + .resource("https://resource") + .build(); + + assertNotNull(mia); + } + + @Test + void managedIdentityApplication_ClientCapabilities_Set() { + ManagedIdentityApplication mia = ManagedIdentityApplication.builder( + ManagedIdentityId.systemAssigned()) + .clientCapabilities(Arrays.asList("cp1", "cp2")) + .build(); + + assertEquals(Arrays.asList("cp1", "cp2"), mia.getClientCapabilities()); + } + + // ========== Builder Option Propagation Tests ========== + + @Test + void builder_LogPii_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .logPii(true) + .build(); + + assertTrue(pca.logPii()); + } + + @Test + void builder_CorrelationId_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .correlationId("test-correlation-id") + .build(); + + assertEquals("test-correlation-id", pca.correlationId()); + } + + @Test + void builder_Proxy_PropagatedToApp() { + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("proxy.example.com", 8080)); + + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .proxy(proxy) + .build(); + + assertEquals(proxy, pca.proxy()); + } + + @Test + void builder_SslSocketFactory_PropagatedToApp() { + SSLSocketFactory sslFactory = mock(SSLSocketFactory.class); + + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .sslSocketFactory(sslFactory) + .build(); + + assertSame(sslFactory, pca.sslSocketFactory()); + } + + @Test + void builder_ExecutorService_PropagatedToApp() { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .executorService(executor) + .build(); + + assertNotNull(pca.serviceBundle().getExecutorService()); + } finally { + executor.shutdown(); + } + } + + @Test + void builder_Timeouts_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .connectTimeoutForDefaultHttpClient(5000) + .readTimeoutForDefaultHttpClient(10000) + .build(); + + assertEquals(5000, pca.connectTimeoutForDefaultHttpClient()); + assertEquals(10000, pca.readTimeoutForDefaultHttpClient()); + } + + @Test + void builder_ApplicationNameAndVersion_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .applicationName("TestApp") + .applicationVersion("1.0.0") + .build(); + + assertEquals("TestApp", pca.applicationName()); + assertEquals("1.0.0", pca.applicationVersion()); + } + + @Test + void builder_ValidateAuthority_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .validateAuthority(false) + .build(); + + assertFalse(pca.validateAuthority()); + } + + @Test + void builder_AutoDetectRegion_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .autoDetectRegion(true) + .build(); + + assertTrue(pca.autoDetectRegion()); + } + + @Test + void builder_AzureRegion_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .azureRegion("westus") + .build(); + + assertEquals("westus", pca.azureRegion()); + } + + @Test + void builder_InstanceDiscovery_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .instanceDiscovery(false) + .build(); + + assertFalse(pca.instanceDiscovery()); + } + + @Test + void builder_ClientCapabilities_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .clientCapabilities(new HashSet<>(Arrays.asList("cp1", "cp2"))) + .build(); + + assertNotNull(pca.clientCapabilities()); + assertTrue(pca.clientCapabilities().contains("cp1")); + } + + @Test + void builder_AadInstanceDiscoveryResponse_PropagatedToApp() { + String discoveryResponse = TestHelper.getInstanceDiscoveryResponse(); + + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .aadInstanceDiscoveryResponse(discoveryResponse) + .build(); + + assertNotNull(pca.aadAadInstanceDiscoveryResponse()); + } + + @Test + void builder_HttpClient_PropagatedToApp() { + IHttpClient httpClient = mock(IHttpClient.class); + + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .httpClient(httpClient) + .build(); + + assertSame(httpClient, pca.httpClient()); + } + + @Test + void builder_DisableInternalRetries_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .disableInternalRetries() + .build(); + + assertTrue(pca.isRetryDisabled()); + } + + @Test + void builder_TokenCacheAccessAspect_PropagatedToApp() { + ITokenCacheAccessAspect aspect = mock(ITokenCacheAccessAspect.class); + + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .setTokenCacheAccessAspect(aspect) + .build(); + + assertNotNull(pca.tokenCache()); + } + + // ========== Builder Null Validation Tests ========== + + @ParameterizedTest(name = "builder.{0}(null) throws") + @MethodSource("nullValidationProvider") + void builder_NullArgument_Throws(String methodName, Runnable action) { + assertThrows(Exception.class, action::run, + "Builder." + methodName + "(null) should throw"); + } + + private static Stream nullValidationProvider() { + return Stream.of( + Arguments.of("executorService", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).executorService(null)), + Arguments.of("proxy", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).proxy(null)), + Arguments.of("sslSocketFactory", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).sslSocketFactory(null)), + Arguments.of("httpClient", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).httpClient(null)), + Arguments.of("connectTimeoutForDefaultHttpClient", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).connectTimeoutForDefaultHttpClient(null)), + Arguments.of("readTimeoutForDefaultHttpClient", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).readTimeoutForDefaultHttpClient(null)), + Arguments.of("applicationName", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).applicationName(null)), + Arguments.of("applicationVersion", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).applicationVersion(null)), + Arguments.of("setTokenCacheAccessAspect", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).setTokenCacheAccessAspect(null)), + Arguments.of("aadInstanceDiscoveryResponse", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).aadInstanceDiscoveryResponse(null)), + Arguments.of("correlationId (blank)", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).correlationId("")) + ); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java index 5c8187570..2448350db 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java @@ -9,7 +9,6 @@ import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -20,7 +19,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class ClientCredentialTest { @Test @@ -47,7 +45,7 @@ void testSecretNullAndEmpty() { } @Test - void OnBehalfOf_InternalCacheLookup_Success() throws Exception { + void clientCredential_InternalCacheLookup_Success() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(new HashMap<>()))); @@ -71,7 +69,7 @@ void OnBehalfOf_InternalCacheLookup_Success() throws Exception { } @Test - void OnBehalfOf_TenantOverride() throws Exception { + void clientCredential_TenantOverride() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); ConfidentialClientApplication cca = From 6ebbb90b72e3c5177df8b1fd8040b172974fa123 Mon Sep 17 00:00:00 2001 From: avdunn Date: Sun, 7 Jun 2026 19:12:58 -0700 Subject: [PATCH 14/24] Parameters tests --- ...AuthorizationRequestUrlParametersTest.java | 177 +++- .../aad/msal4j/ParameterBuilderTest.java | 863 ++++++++++++++++++ 2 files changed, 1017 insertions(+), 23 deletions(-) create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java index a200a24a1..90ef70380 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java @@ -4,22 +4,21 @@ package com.microsoft.aad.msal4j; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; 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 static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.UnsupportedEncodingException; import java.net.URL; import java.net.URLDecoder; import java.util.*; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class AuthorizationRequestUrlParametersTest { @Test - void testBuilder_onlyRequiredParameters() throws UnsupportedEncodingException { + void testBuilder_onlyRequiredParameters() throws Exception { PublicClientApplication app = PublicClientApplication.builder("client_id").build(); String redirectUri = "http://localhost:8080"; @@ -53,16 +52,7 @@ void testBuilder_onlyRequiredParameters() throws UnsupportedEncodingException { assertEquals("login.microsoftonline.com", authorizationUrl.getHost()); assertEquals("/common/oauth2/v2.0/authorize", authorizationUrl.getPath()); - Map queryParameters = new HashMap<>(); - String query = authorizationUrl.getQuery(); - - String[] queryPairs = query.split("&"); - for (String pair : queryPairs) { - int idx = pair.indexOf("="); - queryParameters.put( - URLDecoder.decode(pair.substring(0, idx), "UTF-8"), - URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); - } + Map queryParameters = parseQueryParameters(authorizationUrl); assertEquals("openid profile offline_access scope", queryParameters.get("scope")); assertEquals("code", queryParameters.get("response_type")); @@ -85,6 +75,8 @@ void testBuilder_invalidRequiredParameters() { @Test void testBuilder_conflictingParameters() { + // Verifies that duplicate parameter keys (extra query params overriding built-in params) + // don't throw an exception — they log a warning and the extra value overwrites the built-in. String redirectUri = "http://localhost:8080"; Set scope = Collections.singleton("scope"); @@ -98,7 +90,7 @@ void testBuilder_conflictingParameters() { } @Test - void testBuilder_responseMode() throws UnsupportedEncodingException { + void testBuilder_responseMode() throws Exception { PublicClientApplication app = PublicClientApplication.builder("client_id").build(); String redirectUri = "http://localhost:8080"; @@ -127,9 +119,153 @@ void testBuilder_responseMode() throws UnsupportedEncodingException { assertEquals("login.microsoftonline.com", authorizationUrl.getHost()); assertEquals("/common/oauth2/v2.0/authorize", authorizationUrl.getPath()); - Map queryParameters = new HashMap<>(); - String query = authorizationUrl.getQuery(); + Map queryParameters = parseQueryParameters(authorizationUrl); + + assertEquals("openid profile offline_access scope", queryParameters.get("scope")); + assertEquals("code", queryParameters.get("response_type")); + assertEquals("http://localhost:8080", queryParameters.get("redirect_uri")); + assertEquals("client_id", queryParameters.get("client_id")); + assertEquals("form_post", queryParameters.get("response_mode")); + } + + @Test + void testBuilder_allOptionalParams() throws Exception { + PublicClientApplication app = PublicClientApplication.builder("client_id").build(); + + String redirectUri = "http://localhost:8080"; + Set scope = Collections.singleton("scope"); + + AuthorizationRequestUrlParameters parameters = + AuthorizationRequestUrlParameters + .builder(redirectUri, scope) + .codeChallenge("challenge-value") + .codeChallengeMethod("S256") + .state("state-123") + .nonce("nonce-456") + .loginHint("user@contoso.com") + .domainHint("contoso.com") + .correlationId("corr-789") + .instanceAware(true) + .prompt(Prompt.CONSENT) + .responseMode(ResponseMode.FORM_POST) + .build(); + + assertEquals("challenge-value", parameters.codeChallenge()); + assertEquals("S256", parameters.codeChallengeMethod()); + assertEquals("state-123", parameters.state()); + assertEquals("nonce-456", parameters.nonce()); + assertEquals("corr-789", parameters.correlationId()); + assertTrue(parameters.instanceAware()); + assertEquals(Prompt.CONSENT, parameters.prompt()); + assertEquals(ResponseMode.FORM_POST, parameters.responseMode()); + + URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); + Map queryParameters = parseQueryParameters(authorizationUrl); + + assertEquals("challenge-value", queryParameters.get("code_challenge")); + assertEquals("S256", queryParameters.get("code_challenge_method")); + assertEquals("state-123", queryParameters.get("state")); + assertEquals("nonce-456", queryParameters.get("nonce")); + assertEquals("user@contoso.com", queryParameters.get("login_hint")); + assertEquals("contoso.com", queryParameters.get("domain_hint")); + assertEquals("corr-789", queryParameters.get("correlation_id")); + assertEquals("true", queryParameters.get("instance_aware")); + assertEquals("consent", queryParameters.get("prompt")); + } + + @Test + void testBuilder_nullScopes_Throws() { + assertThrows(IllegalArgumentException.class, () -> + AuthorizationRequestUrlParameters.builder("http://localhost:8080", null)); + } + + @Test + void testBuilder_extraScopesToConsent() throws Exception { + PublicClientApplication app = PublicClientApplication.builder("client_id").build(); + + Set scope = Collections.singleton("User.Read"); + Set extraScopes = new HashSet<>(Arrays.asList("Mail.Read", "Calendars.Read")); + AuthorizationRequestUrlParameters parameters = + AuthorizationRequestUrlParameters + .builder("http://localhost:8080", scope) + .extraScopesToConsent(extraScopes) + .build(); + + // Scopes should include common scopes + requested + extra + Set resultScopes = parameters.scopes(); + assertTrue(resultScopes.contains("User.Read")); + assertTrue(resultScopes.contains("Mail.Read")); + assertTrue(resultScopes.contains("Calendars.Read")); + assertTrue(resultScopes.contains("openid")); + + URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); + Map queryParameters = parseQueryParameters(authorizationUrl); + + String scopeParam = queryParameters.get("scope"); + assertTrue(scopeParam.contains("User.Read")); + assertTrue(scopeParam.contains("Mail.Read")); + assertTrue(scopeParam.contains("Calendars.Read")); + } + + @Test + void testBuilder_loginHint_SetsAnchorMailbox() throws Exception { + PublicClientApplication app = PublicClientApplication.builder("client_id").build(); + + AuthorizationRequestUrlParameters parameters = + AuthorizationRequestUrlParameters + .builder("http://localhost:8080", Collections.singleton("scope")) + .loginHint("user@contoso.com") + .build(); + + URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); + Map queryParameters = parseQueryParameters(authorizationUrl); + + assertEquals("user@contoso.com", queryParameters.get("login_hint")); + // X-AnchorMailbox should be set for CCS routing with UPN format + assertEquals("upn:user@contoso.com", queryParameters.get("X-AnchorMailbox")); + } + + @Test + void testBuilder_formPostResponseMode_ExplicitlySet() throws Exception { + PublicClientApplication app = PublicClientApplication.builder("client_id").build(); + + AuthorizationRequestUrlParameters parameters = + AuthorizationRequestUrlParameters + .builder("http://localhost:8080", Collections.singleton("scope")) + .responseMode(ResponseMode.FORM_POST) + .build(); + + assertEquals(ResponseMode.FORM_POST, parameters.responseMode()); + + URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); + Map queryParameters = parseQueryParameters(authorizationUrl); + + assertEquals("form_post", queryParameters.get("response_mode")); + } + + @Test + void testBuilder_claimsChallenge() throws Exception { + PublicClientApplication app = PublicClientApplication.builder("client_id").build(); + + String claimsChallenge = "{\"access_token\":{\"nbf\":{\"essential\":true}}}"; + + AuthorizationRequestUrlParameters parameters = + AuthorizationRequestUrlParameters + .builder("http://localhost:8080", Collections.singleton("scope")) + .claimsChallenge(claimsChallenge) + .build(); + + URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); + Map queryParameters = parseQueryParameters(authorizationUrl); + + assertNotNull(queryParameters.get("claims")); + assertTrue(queryParameters.get("claims").contains("nbf")); + } + + private static Map parseQueryParameters(URL url) throws Exception { + Map queryParameters = new HashMap<>(); + String query = url.getQuery(); String[] queryPairs = query.split("&"); for (String pair : queryPairs) { int idx = pair.indexOf("="); @@ -137,11 +273,6 @@ void testBuilder_responseMode() throws UnsupportedEncodingException { URLDecoder.decode(pair.substring(0, idx), "UTF-8"), URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); } - - assertEquals("openid profile offline_access scope", queryParameters.get("scope")); - assertEquals("code", queryParameters.get("response_type")); - assertEquals("http://localhost:8080", queryParameters.get("redirect_uri")); - assertEquals("client_id", queryParameters.get("client_id")); - assertEquals("form_post", queryParameters.get("response_mode")); + return queryParameters; } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java new file mode 100644 index 000000000..6d5014b7e --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java @@ -0,0 +1,863 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for all IAcquireTokenParameters builder classes. + * Each section covers: required params build, optional params, validation, and special logic. + */ +class ParameterBuilderTest { + + private static final Set SCOPES = Collections.singleton("User.Read"); + private static final String TENANT = "contoso.onmicrosoft.com"; + private static final Map EXTRA_HEADERS = Collections.singletonMap("x-custom", "value"); + private static final Map EXTRA_QUERY_PARAMS = Collections.singletonMap("param1", "value1"); + + // ========== DeviceCodeFlowParameters ========== + + @Test + void deviceCodeFlow_RequiredParams_GettersReturnExpected() { + Consumer consumer = dc -> {}; + + DeviceCodeFlowParameters params = DeviceCodeFlowParameters + .builder(SCOPES, consumer) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertSame(consumer, params.deviceCodeConsumer()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void deviceCodeFlow_AllOptionalParams_GettersReturnExpected() { + Consumer consumer = dc -> {}; + ClaimsRequest claims = new ClaimsRequest(); + + DeviceCodeFlowParameters params = DeviceCodeFlowParameters + .builder(SCOPES, consumer) + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void deviceCodeFlow_NullScopes_Throws() { + Consumer consumer = dc -> {}; + + assertThrows(IllegalArgumentException.class, () -> + DeviceCodeFlowParameters.builder(null, consumer)); + } + + @Test + void deviceCodeFlow_DeviceCodeConsumerValidation_ValidatesWrongField() { + // BUG: DeviceCodeFlowParameters.Builder.deviceCodeConsumer() validates scopes + // instead of the deviceCodeConsumer parameter (copy-paste bug on line 118). + // Since scopes was already set by builder(scopes, consumer), passing null consumer + // does NOT throw — it silently accepts null. + DeviceCodeFlowParameters params = DeviceCodeFlowParameters + .builder(SCOPES, dc -> {}) + .deviceCodeConsumer(null) + .build(); + + assertNull(params.deviceCodeConsumer()); + } + + // ========== IntegratedWindowsAuthenticationParameters ========== + + @Test + void iwa_RequiredParams_GettersReturnExpected() { + IntegratedWindowsAuthenticationParameters params = + IntegratedWindowsAuthenticationParameters + .builder(SCOPES, "user@contoso.com") + .build(); + + assertEquals(SCOPES, params.scopes()); + assertEquals("user@contoso.com", params.username()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void iwa_AllOptionalParams_GettersReturnExpected() { + ClaimsRequest claims = new ClaimsRequest(); + + IntegratedWindowsAuthenticationParameters params = + IntegratedWindowsAuthenticationParameters + .builder(SCOPES, "user@contoso.com") + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void iwa_NullScopes_Throws() { + assertThrows(IllegalArgumentException.class, () -> + IntegratedWindowsAuthenticationParameters.builder(null, "user@contoso.com")); + } + + @Test + void iwa_BlankUsername_Throws() { + assertThrows(IllegalArgumentException.class, () -> + IntegratedWindowsAuthenticationParameters.builder(SCOPES, "")); + } + + // ========== AppTokenProviderParameters ========== + + @Test + void appTokenProvider_Constructor_GettersReturnExpected() { + Set scopes = Collections.singleton("https://graph.microsoft.com/.default"); + String correlationId = "corr-123"; + String claims = "{\"access_token\":{}}"; + String tenantId = "tenant-456"; + + AppTokenProviderParameters params = new AppTokenProviderParameters( + scopes, correlationId, claims, tenantId); + + assertEquals(scopes, params.getScopes()); + assertEquals(correlationId, params.getCorrelationId()); + assertEquals(claims, params.getClaims()); + assertEquals(tenantId, params.getTenantId()); + } + + @Test + void appTokenProvider_Setters_UpdateValues() { + AppTokenProviderParameters params = new AppTokenProviderParameters( + SCOPES, "corr-1", null, "tenant-1"); + + Set newScopes = Collections.singleton("Mail.Read"); + params.setScopes(newScopes); + params.setCorrelationId("corr-2"); + params.setClaims("new-claims"); + params.setTenantId("tenant-2"); + + assertEquals(newScopes, params.getScopes()); + assertEquals("corr-2", params.getCorrelationId()); + assertEquals("new-claims", params.getClaims()); + assertEquals("tenant-2", params.getTenantId()); + } + + // ========== PopParameters ========== + + @Test + void pop_ValidParams_GettersReturnExpected() throws Exception { + URI uri = new URI("https://graph.microsoft.com/v1.0/me"); + + PopParameters params = new PopParameters(HttpMethod.GET, uri, "test-nonce"); + + assertEquals(HttpMethod.GET, params.getHttpMethod()); + assertEquals(uri, params.getUri()); + assertEquals("test-nonce", params.getNonce()); + } + + @Test + void pop_NullHttpMethod_Throws() throws Exception { + URI uri = new URI("https://graph.microsoft.com/v1.0/me"); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> + new PopParameters(null, uri, "nonce")); + assertTrue(ex.getMessage().contains("HTTP method")); + } + + @Test + void pop_NullUri_Throws() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + new PopParameters(HttpMethod.GET, null, "nonce")); + assertTrue(ex.getMessage().contains("HTTP method")); + } + + @Test + void pop_UriWithNullHost_Throws() throws Exception { + // A URI like "urn:example" has no host component + URI noHostUri = new URI("urn:example"); + assertNull(noHostUri.getHost()); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> + new PopParameters(HttpMethod.GET, noHostUri, "nonce")); + assertTrue(ex.getMessage().contains("HTTP method")); + } + + @Test + void pop_NullNonce_DoesNotThrow() throws Exception { + URI uri = new URI("https://graph.microsoft.com/v1.0/me"); + + PopParameters params = new PopParameters(HttpMethod.POST, uri, null); + + assertEquals(HttpMethod.POST, params.getHttpMethod()); + assertNull(params.getNonce()); + } + + // ========== InteractiveRequestParameters ========== + + @Test + void interactive_RequiredParams_GettersReturnExpected() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(redirectUri) + .build(); + + assertEquals(redirectUri, params.redirectUri()); + assertNull(params.scopes()); + assertNull(params.claims()); + assertNull(params.prompt()); + assertNull(params.loginHint()); + assertNull(params.domainHint()); + assertNull(params.systemBrowserOptions()); + assertNull(params.claimsChallenge()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + assertEquals(120, params.httpPollingTimeoutInSeconds()); + assertFalse(params.instanceAware()); + assertEquals(0L, params.windowHandle()); + assertNull(params.proofOfPossession()); + } + + @Test + void interactive_AllOptionalParams_GettersReturnExpected() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + ClaimsRequest claims = new ClaimsRequest(); + SystemBrowserOptions browserOptions = SystemBrowserOptions.builder().build(); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(redirectUri) + .scopes(SCOPES) + .claims(claims) + .prompt(Prompt.CONSENT) + .loginHint("user@contoso.com") + .domainHint("contoso.com") + .systemBrowserOptions(browserOptions) + .claimsChallenge("{\"access_token\":{}}") + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .httpPollingTimeoutInSeconds(60) + .instanceAware(true) + .windowHandle(12345L) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertSame(claims, params.claims()); + assertEquals(Prompt.CONSENT, params.prompt()); + assertEquals("user@contoso.com", params.loginHint()); + assertEquals("contoso.com", params.domainHint()); + assertSame(browserOptions, params.systemBrowserOptions()); + assertEquals("{\"access_token\":{}}", params.claimsChallenge()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + assertEquals(60, params.httpPollingTimeoutInSeconds()); + assertTrue(params.instanceAware()); + assertEquals(12345L, params.windowHandle()); + } + + @Test + void interactive_NullRedirectUri_Throws() { + assertThrows(IllegalArgumentException.class, () -> + InteractiveRequestParameters.builder(null)); + } + + @Test + void interactive_ProofOfPossession_CreatesPopParameters() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + URI resourceUri = new URI("https://graph.microsoft.com/v1.0/me"); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(redirectUri) + .proofOfPossession(HttpMethod.GET, resourceUri, "nonce-value") + .build(); + + PopParameters pop = params.proofOfPossession(); + assertNotNull(pop); + assertEquals(HttpMethod.GET, pop.getHttpMethod()); + assertEquals(resourceUri, pop.getUri()); + assertEquals("nonce-value", pop.getNonce()); + } + + // ========== SilentParameters ========== + + @Test + void silent_WithAccount_GettersReturnExpected() { + IAccount mockAccount = new IAccount() { + public String homeAccountId() { return "uid.utid"; } + public String environment() { return "login.microsoftonline.com"; } + public String username() { return "user@contoso.com"; } + public Map getTenantProfiles() { return null; } + }; + + SilentParameters params = SilentParameters + .builder(SCOPES, mockAccount) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertSame(mockAccount, params.account()); + assertFalse(params.forceRefresh()); + assertNull(params.claims()); + assertNull(params.authorityUrl()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + assertNull(params.proofOfPossession()); + } + + @Test + void silent_WithoutAccount_GettersReturnExpected() { + SilentParameters params = SilentParameters + .builder(SCOPES) + .forceRefresh(true) + .tenant(TENANT) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertNull(params.account()); + assertTrue(params.forceRefresh()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void silent_RemoveEmptyScope_FiltersEmptyStrings() { + Set scopesWithEmpty = new HashSet<>(); + scopesWithEmpty.add("User.Read"); + scopesWithEmpty.add(""); + scopesWithEmpty.add("Mail.Read"); + + SilentParameters params = SilentParameters + .builder(scopesWithEmpty) + .build(); + + Set resultScopes = params.scopes(); + assertEquals(2, resultScopes.size()); + assertTrue(resultScopes.contains("User.Read")); + assertTrue(resultScopes.contains("Mail.Read")); + assertFalse(resultScopes.contains("")); + } + + @Test + void silent_NullScopes_Throws() { + assertThrows(IllegalArgumentException.class, () -> + SilentParameters.builder(null)); + } + + @Test + void silent_NullAccount_Throws() { + assertThrows(IllegalArgumentException.class, () -> + SilentParameters.builder(SCOPES, null)); + } + + @Test + void silent_ProofOfPossession_CreatesPopParameters() throws Exception { + URI resourceUri = new URI("https://graph.microsoft.com/v1.0/me"); + + SilentParameters params = SilentParameters + .builder(SCOPES) + .proofOfPossession(HttpMethod.POST, resourceUri, "nonce") + .build(); + + PopParameters pop = params.proofOfPossession(); + assertNotNull(pop); + assertEquals(HttpMethod.POST, pop.getHttpMethod()); + assertEquals(resourceUri, pop.getUri()); + } + + // ========== OnBehalfOfParameters ========== + + @Test + void obo_RequiredParams_GettersReturnExpected() { + UserAssertion assertion = new UserAssertion("test-jwt-assertion"); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(SCOPES, assertion) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertSame(assertion, params.userAssertion()); + assertFalse(params.skipCache()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void obo_AllOptionalParams_GettersReturnExpected() { + UserAssertion assertion = new UserAssertion("test-jwt-assertion"); + ClaimsRequest claims = new ClaimsRequest(); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(SCOPES, assertion) + .skipCache(true) + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertTrue(params.skipCache()); + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void obo_NullScopes_Throws() { + UserAssertion assertion = new UserAssertion("test-jwt-assertion"); + + assertThrows(IllegalArgumentException.class, () -> + OnBehalfOfParameters.builder(null, assertion)); + } + + @Test + void obo_SkipCacheNull_DefaultsToFalse() { + UserAssertion assertion = new UserAssertion("test-jwt-assertion"); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(SCOPES, assertion) + .skipCache(null) + .build(); + + // OnBehalfOfParameters constructor: skipCache = skipCache != null && skipCache + assertFalse(params.skipCache()); + } + + @Test + void obo_SkipCacheTrue_ReturnsTrueValue() { + UserAssertion assertion = new UserAssertion("test-jwt-assertion"); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(SCOPES, assertion) + .skipCache(Boolean.TRUE) + .build(); + + assertTrue(params.skipCache()); + } + + // ========== RefreshTokenParameters ========== + + @Test + void refreshToken_RequiredParams_GettersReturnExpected() { + RefreshTokenParameters params = RefreshTokenParameters + .builder(SCOPES, "refresh-token-value") + .build(); + + assertEquals(SCOPES, params.scopes()); + assertEquals("refresh-token-value", params.refreshToken()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void refreshToken_AllOptionalParams_GettersReturnExpected() { + ClaimsRequest claims = new ClaimsRequest(); + + RefreshTokenParameters params = RefreshTokenParameters + .builder(SCOPES, "refresh-token-value") + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void refreshToken_BlankToken_Throws() { + assertThrows(IllegalArgumentException.class, () -> + RefreshTokenParameters.builder(SCOPES, "")); + } + + @Test + void refreshToken_BuilderRefreshTokenValidation_ValidatesWrongField() { + // BUG: RefreshTokenParameters.Builder.refreshToken() validates scopes + // instead of the refreshToken parameter (copy-paste bug on line 116). + // Since scopes was already set by builder(scopes, token), passing null + // to the builder setter does NOT throw. + RefreshTokenParameters params = RefreshTokenParameters + .builder(SCOPES, "initial-token") + .refreshToken(null) + .build(); + + assertNull(params.refreshToken()); + } + + // ========== AuthorizationCodeParameters ========== + + @Test + void authCode_RequiredParams_GettersReturnExpected() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + + AuthorizationCodeParameters params = AuthorizationCodeParameters + .builder("auth-code-123", redirectUri) + .build(); + + assertEquals("auth-code-123", params.authorizationCode()); + assertEquals(redirectUri, params.redirectUri()); + assertNull(params.scopes()); + assertNull(params.claims()); + assertNull(params.codeVerifier()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void authCode_AllOptionalParams_GettersReturnExpected() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + ClaimsRequest claims = new ClaimsRequest(); + + AuthorizationCodeParameters params = AuthorizationCodeParameters + .builder("auth-code-123", redirectUri) + .scopes(SCOPES) + .claims(claims) + .codeVerifier("pkce-verifier") + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertSame(claims, params.claims()); + assertEquals("pkce-verifier", params.codeVerifier()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void authCode_BlankCode_Throws() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + + assertThrows(IllegalArgumentException.class, () -> + AuthorizationCodeParameters.builder("", redirectUri)); + } + + // ========== ClientCredentialParameters ========== + + @Test + void clientCredential_RequiredParams_GettersReturnExpected() { + ClientCredentialParameters params = ClientCredentialParameters + .builder(SCOPES) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertFalse(params.skipCache()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + assertNull(params.clientCredential()); + assertNull(params.fmiPath()); + } + + @Test + void clientCredential_AllOptionalParams_GettersReturnExpected() { + ClaimsRequest claims = new ClaimsRequest(); + IClientCredential credential = ClientCredentialFactory.createFromSecret("test-secret"); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(SCOPES) + .skipCache(true) + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .clientCredential(credential) + .fmiPath("agent-app-id") + .build(); + + assertTrue(params.skipCache()); + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + assertSame(credential, params.clientCredential()); + assertEquals("agent-app-id", params.fmiPath()); + } + + @Test + void clientCredential_NullScopes_Throws() { + assertThrows(IllegalArgumentException.class, () -> + ClientCredentialParameters.builder(null)); + } + + @Test + void clientCredential_FmiPath_ComputesExtCacheKeyHash() { + ClientCredentialParameters params = ClientCredentialParameters + .builder(SCOPES) + .fmiPath("agent-app-id") + .build(); + + // cacheKeyComponents should contain fmi_path + assertNotNull(params.cacheKeyComponents()); + assertEquals("agent-app-id", params.cacheKeyComponents().get("fmi_path")); + + // computeExtCacheKeyHash should return a non-empty string + String hash = params.computeExtCacheKeyHash(); + assertNotNull(hash); + assertFalse(hash.isEmpty()); + + // Second call should return memoized value (same instance) + assertSame(hash, params.computeExtCacheKeyHash()); + } + + @Test + void clientCredential_NoFmiPath_NullCacheKeyComponents() { + ClientCredentialParameters params = ClientCredentialParameters + .builder(SCOPES) + .build(); + + assertNull(params.cacheKeyComponents()); + assertEquals("", params.computeExtCacheKeyHash()); + } + + @Test + void clientCredential_BlankFmiPath_Throws() { + assertThrows(IllegalArgumentException.class, () -> + ClientCredentialParameters.builder(SCOPES).fmiPath("")); + } + + // ========== UserNamePasswordParameters ========== + + @Test + void usernamePassword_RequiredParams_GettersReturnExpected() { + char[] password = "P@ssw0rd".toCharArray(); + + UserNamePasswordParameters params = UserNamePasswordParameters + .builder(SCOPES, "user@contoso.com", password) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertEquals("user@contoso.com", params.username()); + assertArrayEquals(password, params.password()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + assertNull(params.proofOfPossession()); + } + + @Test + void usernamePassword_AllOptionalParams_GettersReturnExpected() throws Exception { + ClaimsRequest claims = new ClaimsRequest(); + URI resourceUri = new URI("https://graph.microsoft.com/v1.0/me"); + + UserNamePasswordParameters params = UserNamePasswordParameters + .builder(SCOPES, "user@contoso.com", "P@ssw0rd".toCharArray()) + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .proofOfPossession(HttpMethod.GET, resourceUri, "nonce") + .build(); + + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + assertNotNull(params.proofOfPossession()); + } + + @Test + void usernamePassword_BlankUsername_Throws() { + assertThrows(IllegalArgumentException.class, () -> + UserNamePasswordParameters.builder(SCOPES, "", "pass".toCharArray())); + } + + @Test + void usernamePassword_EmptyPassword_Throws() { + assertThrows(IllegalArgumentException.class, () -> + UserNamePasswordParameters.builder(SCOPES, "user@contoso.com", new char[0])); + } + + @Test + void usernamePassword_PasswordCloned_NotSameArray() { + char[] original = "P@ssw0rd".toCharArray(); + + UserNamePasswordParameters params = UserNamePasswordParameters + .builder(SCOPES, "user@contoso.com", original) + .build(); + + // password() returns a clone, not the same array + char[] returned = params.password(); + assertArrayEquals(original, returned); + assertNotSame(original, returned); + } + + // ========== UserFederatedIdentityCredentialParameters ========== + + @Test + void userFic_BuilderWithUsername_GettersReturnExpected() { + UserFederatedIdentityCredentialParameters params = + UserFederatedIdentityCredentialParameters + .builder(SCOPES, "user@contoso.com", "jwt-assertion") + .build(); + + assertEquals(SCOPES, params.scopes()); + assertEquals("user@contoso.com", params.username()); + assertNull(params.userObjectId()); + assertEquals("jwt-assertion", params.assertion()); + assertFalse(params.forceRefresh()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void userFic_BuilderWithObjectId_GettersReturnExpected() { + UUID objectId = UUID.randomUUID(); + + UserFederatedIdentityCredentialParameters params = + UserFederatedIdentityCredentialParameters + .builder(SCOPES, objectId, "jwt-assertion") + .build(); + + assertEquals(SCOPES, params.scopes()); + assertNull(params.username()); + assertEquals(objectId, params.userObjectId()); + assertEquals("jwt-assertion", params.assertion()); + } + + @Test + void userFic_AllOptionalParams_GettersReturnExpected() { + ClaimsRequest claims = new ClaimsRequest(); + + UserFederatedIdentityCredentialParameters params = + UserFederatedIdentityCredentialParameters + .builder(SCOPES, "user@contoso.com", "jwt-assertion") + .forceRefresh(true) + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertTrue(params.forceRefresh()); + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void userFic_NullScopes_Throws() { + assertThrows(IllegalArgumentException.class, () -> + UserFederatedIdentityCredentialParameters.builder(null, "user@contoso.com", "assertion")); + } + + @Test + void userFic_BlankAssertion_Throws() { + assertThrows(IllegalArgumentException.class, () -> + UserFederatedIdentityCredentialParameters.builder(SCOPES, "user@contoso.com", "")); + } + + @Test + void userFic_NullObjectId_Throws() { + assertThrows(IllegalArgumentException.class, () -> + UserFederatedIdentityCredentialParameters.builder(SCOPES, (UUID) null, "assertion")); + } + + // ========== ManagedIdentityParameters ========== + + @Test + void managedIdentity_RequiredParams_GettersReturnExpected() { + ManagedIdentityParameters params = ManagedIdentityParameters + .builder("https://management.azure.com") + .build(); + + assertEquals("https://management.azure.com", params.resource()); + assertFalse(params.forceRefresh()); + assertNull(params.claims()); + assertNull(params.scopes()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertEquals(Constants.MANAGED_IDENTITY_DEFAULT_TENTANT, params.tenant()); + assertNull(params.revokedTokenHash()); + } + + @Test + void managedIdentity_ForceRefresh_SetCorrectly() { + ManagedIdentityParameters params = ManagedIdentityParameters + .builder("https://management.azure.com") + .forceRefresh(true) + .build(); + + assertTrue(params.forceRefresh()); + } + + @Test + void managedIdentity_ValidClaims_ParsedAsClaimsRequest() { + String claimsJson = "{\"access_token\":{\"nbf\":{\"essential\":true}}}"; + + ManagedIdentityParameters params = ManagedIdentityParameters + .builder("https://management.azure.com") + .claims(claimsJson) + .build(); + + ClaimsRequest parsedClaims = params.claims(); + assertNotNull(parsedClaims); + } + + @Test + void managedIdentity_NullClaims_ReturnsNull() { + ManagedIdentityParameters params = ManagedIdentityParameters + .builder("https://management.azure.com") + .build(); + + assertNull(params.claims()); + } + + @Test + void managedIdentity_EmptyClaims_Throws() { + // The builder validates claims are not blank, so empty claims can only + // happen if constructed directly. Testing the claims() method's own + // empty-string guard (line 33: if claims == null || claims.isEmpty()). + // Since builder prevents this, we verify the builder validation instead. + assertThrows(IllegalArgumentException.class, () -> + ManagedIdentityParameters.builder("https://management.azure.com").claims("")); + } + + @Test + void managedIdentity_BlankClaims_Throws() { + assertThrows(IllegalArgumentException.class, () -> + ManagedIdentityParameters.builder("https://management.azure.com").claims(" ")); + } +} From 06f3909c4a347389179dfe09fa5d2a2c8f28eb5c Mon Sep 17 00:00:00 2001 From: avdunn Date: Sun, 7 Jun 2026 20:03:15 -0700 Subject: [PATCH 15/24] Supplier tests --- .../aad/msal4j/AcquireTokenSilentlyTest.java | 2 - .../aad/msal4j/AppTokenProviderTest.java | 360 ++++++++++++++++++ .../aad/msal4j/ClientCredentialTest.java | 2 +- .../com/microsoft/aad/msal4j/FmiTest.java | 2 - 4 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java index aae1214d3..f64ebd3c4 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java @@ -4,7 +4,6 @@ package com.microsoft.aad.msal4j; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -20,7 +19,6 @@ import java.util.concurrent.TimeUnit; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class AcquireTokenSilentlyTest { Account basicAccount = new Account("home_account_id", "login.windows.net", "username", null); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java new file mode 100644 index 000000000..1d8dd00a1 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java @@ -0,0 +1,360 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Tests for token acquisition via the app token provider path + * (AcquireTokenByAppProviderSupplier and the appTokenProvider branch + * of AcquireTokenByClientCredentialSupplier). + */ +class AppTokenProviderTest { + + private static final String CLIENT_ID = "test-client-id"; + private static final String AUTHORITY = "https://login.microsoftonline.com/test-tenant/"; + private static final String VALID_ACCESS_TOKEN = "app-provider-access-token"; + private static final String TENANT_ID = "test-tenant"; + private static final long ONE_HOUR_SECONDS = 3600; + private static final long TWO_HOURS_SECONDS = 7200; + + // ======================================================================== + // Helper: builds a CCA with an appTokenProvider + // ======================================================================== + + private ConfidentialClientApplication buildCcaWithProvider( + Function> provider) throws Exception { + return ConfidentialClientApplication + .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret("unused-secret")) + .appTokenProvider(provider) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + } + + private TokenProviderResult validTokenProviderResult() { + TokenProviderResult result = new TokenProviderResult(); + result.setAccessToken(VALID_ACCESS_TOKEN); + result.setExpiresInSeconds(ONE_HOUR_SECONDS); + result.setTenantId(TENANT_ID); + return result; + } + + private Function> providerReturning( + TokenProviderResult result) { + return params -> CompletableFuture.completedFuture(result); + } + + // ======================================================================== + // Valid provider result + // ======================================================================== + + @Test + void appTokenProvider_ValidResult_ReturnsToken() throws Exception { + ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(validTokenProviderResult())); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + IAuthenticationResult result = cca.acquireToken(parameters).get(); + + assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); + } + + @Test + void appTokenProvider_ReceivesCorrectParameters() throws Exception { + // Capture the parameters passed to the provider + @SuppressWarnings("unchecked") + Function> provider = + mock(Function.class); + + when(provider.apply(any())).thenReturn( + CompletableFuture.completedFuture(validTokenProviderResult())); + + ConfidentialClientApplication cca = ConfidentialClientApplication + .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret("unused-secret")) + .appTokenProvider(provider) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .tenant("override-tenant") + .build(); + + cca.acquireToken(parameters).get(); + + verify(provider).apply(argThat(params -> { + assertTrue(params.getScopes().contains("scope/.default")); + assertEquals("override-tenant", params.getTenantId()); + assertNotNull(params.getCorrelationId()); + return true; + })); + } + + @Test + void appTokenProvider_DefaultSkipCache_CallsProviderEachTime() throws Exception { + @SuppressWarnings("unchecked") + Function> provider = + mock(Function.class); + + when(provider.apply(any())).thenReturn( + CompletableFuture.completedFuture(validTokenProviderResult())); + + ConfidentialClientApplication cca = ConfidentialClientApplication + .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret("unused-secret")) + .appTokenProvider(provider) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + // Default skipCache (null) bypasses cache lookup, so provider is called each time + cca.acquireToken(parameters).get(); + cca.acquireToken(parameters).get(); + + verify(provider, times(2)).apply(any()); + } + + @Test + void appTokenProvider_SkipCache_BypassesCacheLookup() throws Exception { + @SuppressWarnings("unchecked") + Function> provider = + mock(Function.class); + + when(provider.apply(any())).thenReturn( + CompletableFuture.completedFuture(validTokenProviderResult())); + + ConfidentialClientApplication cca = ConfidentialClientApplication + .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret("unused-secret")) + .appTokenProvider(provider) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .skipCache(true) + .build(); + + cca.acquireToken(parameters).get(); + cca.acquireToken(parameters).get(); + + // With skipCache=true, provider should be called each time + verify(provider, times(2)).apply(any()); + } + + // ======================================================================== + // Validation: null/empty access token + // ======================================================================== + + @Test + void appTokenProvider_NullAccessToken_Throws() throws Exception { + TokenProviderResult result = validTokenProviderResult(); + result.setAccessToken(null); + + ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> cca.acquireToken(parameters).get()); + assertInstanceOf(MsalClientException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("invalid")); + } + + @Test + void appTokenProvider_EmptyAccessToken_Throws() throws Exception { + TokenProviderResult result = validTokenProviderResult(); + result.setAccessToken(""); + + ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> cca.acquireToken(parameters).get()); + assertInstanceOf(MsalClientException.class, ex.getCause()); + } + + // ======================================================================== + // Validation: invalid expiry + // ======================================================================== + + @Test + void appTokenProvider_ZeroExpiry_Throws() throws Exception { + TokenProviderResult result = validTokenProviderResult(); + result.setExpiresInSeconds(0); + + ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> cca.acquireToken(parameters).get()); + assertInstanceOf(MsalClientException.class, ex.getCause()); + } + + @Test + void appTokenProvider_NegativeExpiry_Throws() throws Exception { + TokenProviderResult result = validTokenProviderResult(); + result.setExpiresInSeconds(-1); + + ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> cca.acquireToken(parameters).get()); + assertInstanceOf(MsalClientException.class, ex.getCause()); + } + + // ======================================================================== + // Validation: null/empty tenant ID + // ======================================================================== + + @Test + void appTokenProvider_NullTenantId_Throws() throws Exception { + TokenProviderResult result = validTokenProviderResult(); + result.setTenantId(null); + + ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> cca.acquireToken(parameters).get()); + assertInstanceOf(MsalClientException.class, ex.getCause()); + } + + @Test + void appTokenProvider_EmptyTenantId_Throws() throws Exception { + TokenProviderResult result = validTokenProviderResult(); + result.setTenantId(""); + + ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> cca.acquireToken(parameters).get()); + assertInstanceOf(MsalClientException.class, ex.getCause()); + } + + // ======================================================================== + // refreshInSeconds auto-calculation + // ======================================================================== + + @Test + void appTokenProvider_ExpiryAtLeastTwoHours_AutoCalculatesRefreshIn() throws Exception { + TokenProviderResult providerResult = validTokenProviderResult(); + providerResult.setExpiresInSeconds(TWO_HOURS_SECONDS); + providerResult.setRefreshInSeconds(0); + + ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(providerResult)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + IAuthenticationResult result = cca.acquireToken(parameters).get(); + + assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); + // refreshOn in metadata should be set to half of expiry (7200/2 = 3600) + assertTrue(result.metadata().refreshOn() > 0, + "refreshOn should be auto-calculated when expiresIn >= 2 hours"); + } + + @Test + void appTokenProvider_ExpiryLessThanTwoHours_NoAutoRefreshIn() throws Exception { + TokenProviderResult providerResult = validTokenProviderResult(); + providerResult.setExpiresInSeconds(TWO_HOURS_SECONDS - 1); + providerResult.setRefreshInSeconds(0); + + ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(providerResult)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + IAuthenticationResult result = cca.acquireToken(parameters).get(); + + assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); + assertEquals(0, result.metadata().refreshOn(), + "refreshOn should not be auto-calculated when expiresIn < 2 hours"); + } + + @Test + void appTokenProvider_ExplicitRefreshIn_NotOverridden() throws Exception { + TokenProviderResult providerResult = validTokenProviderResult(); + providerResult.setExpiresInSeconds(TWO_HOURS_SECONDS); + providerResult.setRefreshInSeconds(1800); // explicit 30-minute refresh + + ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(providerResult)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + IAuthenticationResult result = cca.acquireToken(parameters).get(); + + assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); + // Auto-calculation only triggers when refreshInSeconds == 0 + assertTrue(result.metadata().refreshOn() > 0); + } + + // ======================================================================== + // Provider exception wrapping + // ======================================================================== + + @Test + void appTokenProvider_ProviderThrows_WrappedInMsalAzureSDKException() throws Exception { + Function> throwingProvider = + params -> { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new RuntimeException("Provider failed")); + return future; + }; + + ConfidentialClientApplication cca = buildCcaWithProvider(throwingProvider); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope/.default")) + .build(); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> cca.acquireToken(parameters).get()); + assertInstanceOf(MsalAzureSDKException.class, ex.getCause()); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java index 2448350db..f26aa54c1 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java @@ -63,7 +63,7 @@ void clientCredential_InternalCacheLookup_Success() throws Exception { IAuthenticationResult result = cca.acquireToken(parameters).get(); IAuthenticationResult result2 = cca.acquireToken(parameters).get(); - //OBO flow should perform an internal cache lookup, so similar parameters should only cause one HTTP client call + //Client credential flow should perform an internal cache lookup, so similar parameters should only cause one HTTP client call assertEquals(result.accessToken(), result2.accessToken()); verify(httpClientMock, times(1)).send(any()); } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java index 6ef00adf2..58abdf46f 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java @@ -4,7 +4,6 @@ package com.microsoft.aad.msal4j; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import java.util.Collections; import java.util.HashMap; @@ -21,7 +20,6 @@ * Covers fmi_path body parameter injection, cache key isolation via ext_cache_key, * and assertion context (AssertionRequestOptions) propagation. */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class FmiTest { // ======================================================================== From 94b1a4126a1b937b89072c8bff7f82ce1204eb7f Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 8 Jun 2026 08:10:12 -0700 Subject: [PATCH 16/24] Request tests --- .../aad/msal4j/ApplicationBuilderTest.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java index 97acee59e..edaa21f56 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java @@ -391,6 +391,57 @@ void builder_AzureRegion_PropagatedToApp() { assertEquals("westus", pca.azureRegion()); } + // ======================================================================== + // InteractiveRequest redirect URI validation + // Only rejection cases are testable as unit tests — valid URIs proceed + // to open a real browser, making them integration test territory. + // ======================================================================== + + @Test + void interactiveRequest_HttpsScheme_ThrowsMsalClientException() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .instanceDiscovery(false) + .build(); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(new URI("https://localhost")) + .scopes(Collections.singleton("scope")) + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> pca.acquireToken(params)); + assertEquals(AuthenticationErrorCode.LOOPBACK_REDIRECT_URI, ex.errorCode()); + } + + @Test + void interactiveRequest_CustomScheme_ThrowsMsalClientException() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .instanceDiscovery(false) + .build(); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(new URI("myapp://callback")) + .scopes(Collections.singleton("scope")) + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> pca.acquireToken(params)); + assertEquals(AuthenticationErrorCode.LOOPBACK_REDIRECT_URI, ex.errorCode()); + } + + @Test + void interactiveRequest_NonLoopbackHost_ThrowsMsalClientException() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .instanceDiscovery(false) + .build(); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(new URI("http://example.com")) + .scopes(Collections.singleton("scope")) + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> pca.acquireToken(params)); + assertEquals(AuthenticationErrorCode.LOOPBACK_REDIRECT_URI, ex.errorCode()); + } + @Test void builder_InstanceDiscovery_PropagatedToApp() { PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) From 1d1c5fcb5167b00d2934bbd05ad2fa8d47a53d31 Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 8 Jun 2026 10:14:12 -0700 Subject: [PATCH 17/24] Mocking improvements and small fixes --- .../aad/msal4j/AcquireTokenSilentlyTest.java | 8 +- .../aad/msal4j/AppTokenProviderTest.java | 225 +++++------------- .../aad/msal4j/ApplicationBuilderTest.java | 2 +- .../com/microsoft/aad/msal4j/CacheTest.java | 13 +- .../aad/msal4j/ClientCredentialTest.java | 28 +-- .../microsoft/aad/msal4j/OnBehalfOfTest.java | 30 +-- .../com/microsoft/aad/msal4j/TestHelper.java | 100 +++++++- 7 files changed, 175 insertions(+), 231 deletions(-) diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java index f64ebd3c4..036a4f040 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java @@ -117,13 +117,7 @@ void confidentialAppAcquireTokenSilently_claimsSkipCache() throws Throwable { void testTokenRefreshReasons() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant/") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); HashMap responseParameters = new HashMap<>(); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java index 1d8dd00a1..665ecb02f 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java @@ -4,11 +4,16 @@ package com.microsoft.aad.msal4j; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import java.util.Collections; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -21,29 +26,16 @@ */ class AppTokenProviderTest { - private static final String CLIENT_ID = "test-client-id"; - private static final String AUTHORITY = "https://login.microsoftonline.com/test-tenant/"; private static final String VALID_ACCESS_TOKEN = "app-provider-access-token"; private static final String TENANT_ID = "test-tenant"; private static final long ONE_HOUR_SECONDS = 3600; private static final long TWO_HOURS_SECONDS = 7200; // ======================================================================== - // Helper: builds a CCA with an appTokenProvider + // Helpers // ======================================================================== - private ConfidentialClientApplication buildCcaWithProvider( - Function> provider) throws Exception { - return ConfidentialClientApplication - .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret("unused-secret")) - .appTokenProvider(provider) - .authority(AUTHORITY) - .instanceDiscovery(false) - .validateAuthority(false) - .build(); - } - - private TokenProviderResult validTokenProviderResult() { + private static TokenProviderResult validTokenProviderResult() { TokenProviderResult result = new TokenProviderResult(); result.setAccessToken(VALID_ACCESS_TOKEN); result.setExpiresInSeconds(ONE_HOUR_SECONDS); @@ -51,31 +43,34 @@ private TokenProviderResult validTokenProviderResult() { return result; } - private Function> providerReturning( + private static Function> providerReturning( TokenProviderResult result) { return params -> CompletableFuture.completedFuture(result); } + private static TokenProviderResult invalidResult(Consumer mutator) { + TokenProviderResult result = validTokenProviderResult(); + mutator.accept(result); + return result; + } + // ======================================================================== // Valid provider result // ======================================================================== @Test void appTokenProvider_ValidResult_ReturnsToken() throws Exception { - ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(validTokenProviderResult())); - - ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(validTokenProviderResult())); - IAuthenticationResult result = cca.acquireToken(parameters).get(); + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get(); assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); } @Test void appTokenProvider_ReceivesCorrectParameters() throws Exception { - // Capture the parameters passed to the provider @SuppressWarnings("unchecked") Function> provider = mock(Function.class); @@ -83,23 +78,17 @@ void appTokenProvider_ReceivesCorrectParameters() throws Exception { when(provider.apply(any())).thenReturn( CompletableFuture.completedFuture(validTokenProviderResult())); - ConfidentialClientApplication cca = ConfidentialClientApplication - .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret("unused-secret")) - .appTokenProvider(provider) - .authority(AUTHORITY) - .instanceDiscovery(false) - .validateAuthority(false) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(provider); ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) + .builder(TestHelper.TEST_SCOPE_SET) .tenant("override-tenant") .build(); cca.acquireToken(parameters).get(); verify(provider).apply(argThat(params -> { - assertTrue(params.getScopes().contains("scope/.default")); + assertTrue(params.getScopes().contains(TestHelper.TEST_SCOPE)); assertEquals("override-tenant", params.getTenantId()); assertNotNull(params.getCorrelationId()); return true; @@ -115,17 +104,10 @@ void appTokenProvider_DefaultSkipCache_CallsProviderEachTime() throws Exception when(provider.apply(any())).thenReturn( CompletableFuture.completedFuture(validTokenProviderResult())); - ConfidentialClientApplication cca = ConfidentialClientApplication - .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret("unused-secret")) - .appTokenProvider(provider) - .authority(AUTHORITY) - .instanceDiscovery(false) - .validateAuthority(false) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(provider); ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); + .builder(TestHelper.TEST_SCOPE_SET).build(); // Default skipCache (null) bypasses cache lookup, so provider is called each time cca.acquireToken(parameters).get(); @@ -143,16 +125,10 @@ void appTokenProvider_SkipCache_BypassesCacheLookup() throws Exception { when(provider.apply(any())).thenReturn( CompletableFuture.completedFuture(validTokenProviderResult())); - ConfidentialClientApplication cca = ConfidentialClientApplication - .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret("unused-secret")) - .appTokenProvider(provider) - .authority(AUTHORITY) - .instanceDiscovery(false) - .validateAuthority(false) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(provider); ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) + .builder(TestHelper.TEST_SCOPE_SET) .skipCache(true) .build(); @@ -164,112 +140,31 @@ void appTokenProvider_SkipCache_BypassesCacheLookup() throws Exception { } // ======================================================================== - // Validation: null/empty access token + // Validation: invalid TokenProviderResult fields // ======================================================================== - @Test - void appTokenProvider_NullAccessToken_Throws() throws Exception { - TokenProviderResult result = validTokenProviderResult(); - result.setAccessToken(null); - - ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); + @ParameterizedTest(name = "appTokenProvider_InvalidResult_{0}_Throws") + @MethodSource("invalidTokenProviderResults") + void appTokenProvider_InvalidResult_Throws(String scenario, TokenProviderResult result) throws Exception { + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(providerReturning(result)); ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); + .builder(TestHelper.TEST_SCOPE_SET).build(); ExecutionException ex = assertThrows(ExecutionException.class, () -> cca.acquireToken(parameters).get()); assertInstanceOf(MsalClientException.class, ex.getCause()); - assertTrue(ex.getCause().getMessage().contains("invalid")); } - @Test - void appTokenProvider_EmptyAccessToken_Throws() throws Exception { - TokenProviderResult result = validTokenProviderResult(); - result.setAccessToken(""); - - ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); - - ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); - - ExecutionException ex = assertThrows(ExecutionException.class, - () -> cca.acquireToken(parameters).get()); - assertInstanceOf(MsalClientException.class, ex.getCause()); - } - - // ======================================================================== - // Validation: invalid expiry - // ======================================================================== - - @Test - void appTokenProvider_ZeroExpiry_Throws() throws Exception { - TokenProviderResult result = validTokenProviderResult(); - result.setExpiresInSeconds(0); - - ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); - - ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); - - ExecutionException ex = assertThrows(ExecutionException.class, - () -> cca.acquireToken(parameters).get()); - assertInstanceOf(MsalClientException.class, ex.getCause()); - } - - @Test - void appTokenProvider_NegativeExpiry_Throws() throws Exception { - TokenProviderResult result = validTokenProviderResult(); - result.setExpiresInSeconds(-1); - - ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); - - ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); - - ExecutionException ex = assertThrows(ExecutionException.class, - () -> cca.acquireToken(parameters).get()); - assertInstanceOf(MsalClientException.class, ex.getCause()); - } - - // ======================================================================== - // Validation: null/empty tenant ID - // ======================================================================== - - @Test - void appTokenProvider_NullTenantId_Throws() throws Exception { - TokenProviderResult result = validTokenProviderResult(); - result.setTenantId(null); - - ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); - - ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); - - ExecutionException ex = assertThrows(ExecutionException.class, - () -> cca.acquireToken(parameters).get()); - assertInstanceOf(MsalClientException.class, ex.getCause()); - } - - @Test - void appTokenProvider_EmptyTenantId_Throws() throws Exception { - TokenProviderResult result = validTokenProviderResult(); - result.setTenantId(""); - - ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(result)); - - ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); - - ExecutionException ex = assertThrows(ExecutionException.class, - () -> cca.acquireToken(parameters).get()); - assertInstanceOf(MsalClientException.class, ex.getCause()); + private static Stream invalidTokenProviderResults() { + return Stream.of( + Arguments.of("NullAccessToken", invalidResult(r -> r.setAccessToken(null))), + Arguments.of("EmptyAccessToken", invalidResult(r -> r.setAccessToken(""))), + Arguments.of("ZeroExpiry", invalidResult(r -> r.setExpiresInSeconds(0))), + Arguments.of("NegativeExpiry", invalidResult(r -> r.setExpiresInSeconds(-1))), + Arguments.of("NullTenantId", invalidResult(r -> r.setTenantId(null))), + Arguments.of("EmptyTenantId", invalidResult(r -> r.setTenantId(""))) + ); } // ======================================================================== @@ -282,16 +177,13 @@ void appTokenProvider_ExpiryAtLeastTwoHours_AutoCalculatesRefreshIn() throws Exc providerResult.setExpiresInSeconds(TWO_HOURS_SECONDS); providerResult.setRefreshInSeconds(0); - ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(providerResult)); + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(providerResult)); - ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); - - IAuthenticationResult result = cca.acquireToken(parameters).get(); + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get(); assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); - // refreshOn in metadata should be set to half of expiry (7200/2 = 3600) assertTrue(result.metadata().refreshOn() > 0, "refreshOn should be auto-calculated when expiresIn >= 2 hours"); } @@ -302,13 +194,11 @@ void appTokenProvider_ExpiryLessThanTwoHours_NoAutoRefreshIn() throws Exception providerResult.setExpiresInSeconds(TWO_HOURS_SECONDS - 1); providerResult.setRefreshInSeconds(0); - ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(providerResult)); - - ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(providerResult)); - IAuthenticationResult result = cca.acquireToken(parameters).get(); + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get(); assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); assertEquals(0, result.metadata().refreshOn(), @@ -321,13 +211,11 @@ void appTokenProvider_ExplicitRefreshIn_NotOverridden() throws Exception { providerResult.setExpiresInSeconds(TWO_HOURS_SECONDS); providerResult.setRefreshInSeconds(1800); // explicit 30-minute refresh - ConfidentialClientApplication cca = buildCcaWithProvider(providerReturning(providerResult)); + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(providerResult)); - ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); - - IAuthenticationResult result = cca.acquireToken(parameters).get(); + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get(); assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); // Auto-calculation only triggers when refreshInSeconds == 0 @@ -347,14 +235,11 @@ void appTokenProvider_ProviderThrows_WrappedInMsalAzureSDKException() throws Exc return future; }; - ConfidentialClientApplication cca = buildCcaWithProvider(throwingProvider); - - ClientCredentialParameters parameters = ClientCredentialParameters - .builder(Collections.singleton("scope/.default")) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(throwingProvider); ExecutionException ex = assertThrows(ExecutionException.class, - () -> cca.acquireToken(parameters).get()); + () -> cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get()); assertInstanceOf(MsalAzureSDKException.class, ex.getCause()); } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java index edaa21f56..ff8d71f2c 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java @@ -26,7 +26,7 @@ class ApplicationBuilderTest { - private static final String CLIENT_ID = "test-client-id"; + private static final String CLIENT_ID = TestHelper.TEST_CLIENT_ID; private static final String DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common/"; // ========== PublicClientApplication Tests ========== diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java index a417b5c2d..163d3aeeb 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java @@ -16,7 +16,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; class CacheTest { @@ -31,20 +30,14 @@ void setUp() { void cacheLookup_MixAccountBasedAndAssertionBasedSilentFlows() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant/") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); HashMap responseParameters = new HashMap<>(); //Acquire a token with no ID token/account associated with it responseParameters.put("access_token", "accessTokenNoAccount"); ClientCredentialParameters clientCredentialParameters = ClientCredentialParameters.builder(Collections.singleton("someScopes")).build(); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(responseParameters))); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, responseParameters); IAuthenticationResult resultNoAccount = cca.acquireToken(clientCredentialParameters).get(); //Ensure there is one token in the cache, and the result had no account @@ -56,7 +49,7 @@ void cacheLookup_MixAccountBasedAndAssertionBasedSilentFlows() throws Exception responseParameters.put("access_token", "accessTokenWithAccount"); responseParameters.put("id_token", TestHelper.createIdToken(new HashMap<>())); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(responseParameters))); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, responseParameters); OnBehalfOfParameters onBehalfOfParameters = OnBehalfOfParameters.builder(Collections.singleton("someOtherScopes"), new UserAssertion(TestHelper.signedAssertion)).build(); IAuthenticationResult resultWithAccount = cca.acquireToken(onBehalfOfParameters).get(); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java index f26aa54c1..5129d3a97 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java @@ -48,17 +48,11 @@ void testSecretNullAndEmpty() { void clientCredential_InternalCacheLookup_Success() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(new HashMap<>()))); + TestHelper.mockSuccessfulTokenResponse(httpClientMock); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant/") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); - ClientCredentialParameters parameters = ClientCredentialParameters.builder(Collections.singleton("scopes")).build(); + ClientCredentialParameters parameters = ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build(); IAuthenticationResult result = cca.acquireToken(parameters).get(); IAuthenticationResult result2 = cca.acquireToken(parameters).get(); @@ -72,19 +66,13 @@ void clientCredential_InternalCacheLookup_Success() throws Exception { void clientCredential_TenantOverride() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); HashMap tokenResponseValues = new HashMap<>(); tokenResponseValues.put("access_token", "accessTokenFirstCall"); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(tokenResponseValues))); - ClientCredentialParameters parameters = ClientCredentialParameters.builder(Collections.singleton("scopes")).build(); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, tokenResponseValues); + ClientCredentialParameters parameters = ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build(); //The two acquireToken calls have the same parameters... IAuthenticationResult resultAppLevelTenant = cca.acquireToken(parameters).get(); @@ -96,8 +84,8 @@ void clientCredential_TenantOverride() throws Exception { tokenResponseValues.put("access_token", "accessTokenSecondCall"); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(tokenResponseValues))); - parameters = ClientCredentialParameters.builder(Collections.singleton("scopes")).tenant("otherTenant").build(); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, tokenResponseValues); + parameters = ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).tenant("otherTenant").build(); //Overriding the tenant parameter in the request should lead to a new token call being made... IAuthenticationResult resultRequestLevelTenant = cca.acquireToken(parameters).get(); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTest.java index b48d0eb3f..0c0a34ba2 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTest.java @@ -3,7 +3,6 @@ package com.microsoft.aad.msal4j; -import java.util.Collections; import java.util.HashMap; import org.junit.jupiter.api.Test; @@ -15,7 +14,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class OnBehalfOfTest { @@ -24,17 +22,11 @@ class OnBehalfOfTest { void OnBehalfOf_InternalCacheLookup_Success() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(new HashMap<>()))); + TestHelper.mockSuccessfulTokenResponse(httpClientMock); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant/") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); - OnBehalfOfParameters parameters = OnBehalfOfParameters.builder(Collections.singleton("scopes"), new UserAssertion(TestHelper.signedAssertion)).build(); + OnBehalfOfParameters parameters = OnBehalfOfParameters.builder(TestHelper.TEST_SCOPE_SET, new UserAssertion(TestHelper.signedAssertion)).build(); IAuthenticationResult result = cca.acquireToken(parameters).get(); IAuthenticationResult result2 = cca.acquireToken(parameters).get(); @@ -48,19 +40,13 @@ void OnBehalfOf_InternalCacheLookup_Success() throws Exception { void OnBehalfOf_TenantOverride() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); HashMap tokenResponseValues = new HashMap<>(); tokenResponseValues.put("access_token", "accessTokenFirstCall"); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(tokenResponseValues))); - OnBehalfOfParameters parameters = OnBehalfOfParameters.builder(Collections.singleton("scopes"), new UserAssertion(TestHelper.signedAssertion)).build(); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, tokenResponseValues); + OnBehalfOfParameters parameters = OnBehalfOfParameters.builder(TestHelper.TEST_SCOPE_SET, new UserAssertion(TestHelper.signedAssertion)).build(); //The two acquireToken calls have the same parameters... IAuthenticationResult resultAppLevelTenant = cca.acquireToken(parameters).get(); @@ -72,8 +58,8 @@ void OnBehalfOf_TenantOverride() throws Exception { tokenResponseValues.put("access_token", "accessTokenSecondCall"); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(tokenResponseValues))); - parameters = OnBehalfOfParameters.builder(Collections.singleton("scopes"), new UserAssertion(TestHelper.signedAssertion)).tenant("otherTenant").build(); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, tokenResponseValues); + parameters = OnBehalfOfParameters.builder(TestHelper.TEST_SCOPE_SET, new UserAssertion(TestHelper.signedAssertion)).tenant("otherTenant").build(); //Overriding the tenant parameter in the request should lead to a new token call being made... IAuthenticationResult resultRequestLevelTenant = cca.acquireToken(parameters).get(); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java index 6a46d2b7a..f7cc377f9 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java @@ -6,6 +6,7 @@ import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.net.MalformedURLException; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -17,12 +18,87 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.when; class TestHelper { + // --- Common test constants --- + + static final String TEST_CLIENT_ID = "test-client-id"; + static final String TEST_SECRET = "test-secret"; + static final String TEST_AUTHORITY = "https://login.microsoftonline.com/test-tenant/"; + static final String TEST_SCOPE = "scope/.default"; + static final Set TEST_SCOPE_SET = Collections.singleton(TEST_SCOPE); + + // --- Application builder helpers --- + + /** + * Builds a CCA with common test defaults: fixed client ID/secret, test authority, + * instanceDiscovery=false, validateAuthority=false, and a mocked HTTP client. + */ + static ConfidentialClientApplication buildCca(IHttpClient httpClientMock) { + try { + return ConfidentialClientApplication + .builder(TEST_CLIENT_ID, ClientCredentialFactory.createFromSecret(TEST_SECRET)) + .authority(TEST_AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + /** + * Builds a CCA with a custom credential and mocked HTTP client. + * Use when the test needs to verify which credential is sent (e.g., credential precedence tests). + */ + static ConfidentialClientApplication buildCca(IClientCredential credential, IHttpClient httpClientMock) { + try { + return ConfidentialClientApplication + .builder(TEST_CLIENT_ID, credential) + .authority(TEST_AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + /** + * Builds a CCA with an appTokenProvider for testing the app token provider flow. + */ + static ConfidentialClientApplication buildCcaWithAppTokenProvider( + Function> provider) { + try { + return ConfidentialClientApplication + .builder(TEST_CLIENT_ID, ClientCredentialFactory.createFromSecret(TEST_SECRET)) + .appTokenProvider(provider) + .authority(TEST_AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + /** Builds a PCA with common test defaults: fixed client ID, instanceDiscovery=false. */ + static PublicClientApplication buildPca() { + return PublicClientApplication.builder(TEST_CLIENT_ID) + .instanceDiscovery(false) + .build(); + } + //Signed JWT which should be enough to pass the parsing/validation in the library, useful if a unit test needs an // assertion but that is not the focus of the test static String signedAssertion = generateToken(); @@ -161,7 +237,7 @@ static String getSuccessfulTokenResponse(HashMap responseValues) Long.parseLong(responseValues.get("expires_in")) : 3600; long expiresOn = responseValues.containsKey("expires_on") - ? Long.parseLong(responseValues.get("expires_0n")) : + ? Long.parseLong(responseValues.get("expires_on")) : (System.currentTimeMillis() / 1000) + expiresIn; long refreshIn = responseValues.containsKey("refresh_in") ? Long.parseLong(responseValues.get("refresh_in")) : @@ -202,6 +278,28 @@ static void createTokenRequestMock(IHttpClient httpClientMock, String expectedRe } } + /** + * Sets up a mocked HTTP client to return a successful token response for any request. + * Use when the test doesn't need to customize the response values. + */ + static void mockSuccessfulTokenResponse(IHttpClient httpClientMock) { + mockSuccessfulTokenResponse(httpClientMock, new HashMap<>()); + } + + /** + * Sets up a mocked HTTP client to return a successful token response with custom values. + * Replaces the common 3-line pattern: + * {@code when(httpClientMock.send(any())).thenReturn(TestHelper.expectedResponse(...))} + */ + static void mockSuccessfulTokenResponse(IHttpClient httpClientMock, HashMap responseValues) { + try { + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn(expectedResponse(HttpStatus.HTTP_OK, getSuccessfulTokenResponse(responseValues))); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + //Maps various values to the idTokenFormat string static String createIdToken(HashMap idTokenValues) { String tokenValues = String.format(idTokenFormat, From 5ef4a64141ea90534951bbc24877c05692986fd4 Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 8 Jun 2026 14:26:58 -0700 Subject: [PATCH 18/24] Response tests --- .../aad/msal4j/ResponseParsingTest.java | 519 ++++++++++++++++++ .../aad/msal4j/WSTrustResponseTest.java | 145 ++++- 2 files changed, 640 insertions(+), 24 deletions(-) create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java new file mode 100644 index 000000000..0ee998fc0 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java @@ -0,0 +1,519 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import com.azure.json.JsonProviders; +import com.azure.json.JsonReader; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ResponseParsingTest { + + // ========== ErrorResponse ========== + + @Test + void errorResponse_fromJson_allFieldsPopulated() throws IOException { + String json = "{\"error\":\"invalid_grant\"," + + "\"error_description\":\"Token expired\"," + + "\"error_codes\":[50076,70008]," + + "\"suberror\":\"basic_action\"," + + "\"trace_id\":\"abc-123\"," + + "\"timestamp\":\"2024-01-15 12:00:00Z\"," + + "\"correlation_id\":\"corr-456\"," + + "\"claims\":\"{\\\"access_token\\\":{\\\"nbf\\\":{\\\"essential\\\":true}}}\"}"; + + ErrorResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = ErrorResponse.fromJson(reader); + } + + assertEquals("invalid_grant", response.error()); + assertEquals("Token expired", response.errorDescription()); + assertArrayEquals(new long[]{50076, 70008}, response.errorCodes()); + assertEquals("basic_action", response.subError()); + assertEquals("abc-123", response.traceId()); + assertEquals("2024-01-15 12:00:00Z", response.timestamp()); + assertEquals("corr-456", response.correlation_id()); + assertTrue(response.claims().contains("access_token")); + } + + @Test + void errorResponse_fromJson_minimalFields() throws IOException { + String json = "{\"error\":\"server_error\"}"; + + ErrorResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = ErrorResponse.fromJson(reader); + } + + assertEquals("server_error", response.error()); + assertNull(response.errorDescription()); + assertNull(response.errorCodes()); + assertNull(response.subError()); + assertNull(response.traceId()); + assertNull(response.timestamp()); + assertNull(response.correlation_id()); + assertNull(response.claims()); + } + + @Test + void errorResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"error\":\"invalid_request\",\"unknown_field\":\"value\",\"another\":123}"; + + ErrorResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = ErrorResponse.fromJson(reader); + } + + assertEquals("invalid_request", response.error()); + } + + @Test + void errorResponse_fromJson_emptyErrorCodes() throws IOException { + String json = "{\"error\":\"test\",\"error_codes\":[]}"; + + ErrorResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = ErrorResponse.fromJson(reader); + } + + assertNotNull(response.errorCodes()); + assertEquals(0, response.errorCodes().length); + } + + @Test + void errorResponse_settersAndGetters_fullCoverage() { + ErrorResponse response = new ErrorResponse(); + + response.statusCode(401); + response.statusMessage("Unauthorized"); + response.error("invalid_token"); + response.errorDescription("The token is expired"); + response.errorCodes(new long[]{50013}); + response.subError("token_expired"); + response.traceId("trace-789"); + response.timestamp("2024-06-01"); + response.correlation_id("corr-id"); + response.claims("{\"id_token\":{}}"); + + assertEquals(401, response.statusCode().intValue()); + assertEquals("Unauthorized", response.statusMessage()); + assertEquals("invalid_token", response.error()); + assertEquals("The token is expired", response.errorDescription()); + assertArrayEquals(new long[]{50013}, response.errorCodes()); + assertEquals("token_expired", response.subError()); + assertEquals("trace-789", response.traceId()); + assertEquals("2024-06-01", response.timestamp()); + assertEquals("corr-id", response.correlation_id()); + assertEquals("{\"id_token\":{}}", response.claims()); + } + + @Test + void errorResponse_toJson_throwsDueToDoubleStartObject() { + // Bug: ErrorResponse.toJson() calls writeStartObject() twice (lines 68, 70) + // which causes an IllegalStateException from the JSON writer + ErrorResponse response = new ErrorResponse(); + response.statusCode(400); + response.error("invalid_grant"); + + assertThrows(IllegalStateException.class, () -> writeToJson(response), + "toJson has a double writeStartObject bug that causes IllegalStateException"); + } + + @Test + void errorResponse_toJson_nullErrorCodes_throwsDueToDoubleStartObject() { + // Same double writeStartObject bug affects all toJson calls + ErrorResponse response = new ErrorResponse(); + response.statusCode(500); + response.error("server_error"); + + assertThrows(IllegalStateException.class, () -> writeToJson(response)); + } + + // ========== UserDiscoveryResponse ========== + + @Test + void userDiscoveryResponse_fromJson_federatedAccount() throws IOException { + String json = "{\"ver\":\"1.0\"," + + "\"account_type\":\"Federated\"," + + "\"federation_metadata_url\":\"https://adfs.example.com/metadata\"," + + "\"federation_protocol\":\"WSTrust\"," + + "\"federation_active_auth_url\":\"https://adfs.example.com/active\"," + + "\"cloud_audience_urn\":\"urn:federation:MicrosoftOnline\"}"; + + UserDiscoveryResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = UserDiscoveryResponse.fromJson(reader); + } + + assertEquals(1.0f, response.version(), 0.01f); + assertEquals("Federated", response.accountType()); + assertEquals("https://adfs.example.com/metadata", response.federationMetadataUrl()); + assertEquals("WSTrust", response.federationProtocol()); + assertEquals("https://adfs.example.com/active", response.federationActiveAuthUrl()); + assertEquals("urn:federation:MicrosoftOnline", response.cloudAudienceUrn()); + assertTrue(response.isAccountFederated()); + assertFalse(response.isAccountManaged()); + } + + @Test + void userDiscoveryResponse_fromJson_managedAccount() throws IOException { + String json = "{\"ver\":\"1.0\",\"account_type\":\"Managed\"}"; + + UserDiscoveryResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = UserDiscoveryResponse.fromJson(reader); + } + + assertTrue(response.isAccountManaged()); + assertFalse(response.isAccountFederated()); + } + + @Test + void userDiscoveryResponse_fromJson_unknownAccountType() throws IOException { + String json = "{\"ver\":\"2.0\",\"account_type\":\"Unknown\"}"; + + UserDiscoveryResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = UserDiscoveryResponse.fromJson(reader); + } + + assertFalse(response.isAccountFederated()); + assertFalse(response.isAccountManaged()); + } + + @Test + void userDiscoveryResponse_isAccountFederated_nullAccountType() { + // Default-constructed response has null accountType + UserDiscoveryResponse response = new UserDiscoveryResponse(); + + assertFalse(response.isAccountFederated()); + assertFalse(response.isAccountManaged()); + } + + @Test + void userDiscoveryResponse_isAccountFederated_caseInsensitive() throws IOException { + String json = "{\"ver\":\"1.0\",\"account_type\":\"fEdErAtEd\"}"; + + UserDiscoveryResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = UserDiscoveryResponse.fromJson(reader); + } + + assertTrue(response.isAccountFederated()); + } + + @Test + void userDiscoveryResponse_isAccountManaged_caseInsensitive() throws IOException { + String json = "{\"ver\":\"1.0\",\"account_type\":\"MANAGED\"}"; + + UserDiscoveryResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = UserDiscoveryResponse.fromJson(reader); + } + + assertTrue(response.isAccountManaged()); + } + + @Test + void userDiscoveryResponse_toJson_roundTrip() throws IOException { + String json = "{\"ver\":\"1.0\"," + + "\"account_type\":\"Federated\"," + + "\"federation_metadata_url\":\"https://adfs.example.com/metadata\"," + + "\"federation_protocol\":\"WSTrust\"," + + "\"federation_active_auth_url\":\"https://adfs.example.com/active\"," + + "\"cloud_audience_urn\":\"urn:federation:MicrosoftOnline\"}"; + + UserDiscoveryResponse original; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + original = UserDiscoveryResponse.fromJson(reader); + } + + String serialized = writeToJson(original); + + UserDiscoveryResponse roundTripped; + try (JsonReader reader = JsonProviders.createReader(new StringReader(serialized))) { + roundTripped = UserDiscoveryResponse.fromJson(reader); + } + + assertEquals(original.accountType(), roundTripped.accountType()); + assertEquals(original.federationProtocol(), roundTripped.federationProtocol()); + assertEquals(original.federationMetadataUrl(), roundTripped.federationMetadataUrl()); + assertEquals(original.federationActiveAuthUrl(), roundTripped.federationActiveAuthUrl()); + assertEquals(original.cloudAudienceUrn(), roundTripped.cloudAudienceUrn()); + } + + @Test + void userDiscoveryResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"ver\":\"1.0\",\"account_type\":\"Managed\",\"extra_field\":\"ignored\"}"; + + UserDiscoveryResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = UserDiscoveryResponse.fromJson(reader); + } + + assertTrue(response.isAccountManaged()); + } + + // ========== OidcDiscoveryResponse ========== + + @Test + void oidcDiscoveryResponse_fromJson_allEndpoints() throws IOException { + String json = "{\"authorization_endpoint\":\"https://login.microsoftonline.com/common/oauth2/v2.0/authorize\"," + + "\"token_endpoint\":\"https://login.microsoftonline.com/common/oauth2/v2.0/token\"," + + "\"device_authorization_endpoint\":\"https://login.microsoftonline.com/common/oauth2/v2.0/devicecode\"," + + "\"issuer\":\"https://login.microsoftonline.com/{tenantid}/v2.0\"}"; + + OidcDiscoveryResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = OidcDiscoveryResponse.fromJson(reader); + } + + assertEquals("https://login.microsoftonline.com/common/oauth2/v2.0/authorize", response.authorizationEndpoint()); + assertEquals("https://login.microsoftonline.com/common/oauth2/v2.0/token", response.tokenEndpoint()); + assertEquals("https://login.microsoftonline.com/common/oauth2/v2.0/devicecode", response.deviceCodeEndpoint()); + assertEquals("https://login.microsoftonline.com/{tenantid}/v2.0", response.issuer()); + } + + @Test + void oidcDiscoveryResponse_fromJson_partialFields() throws IOException { + String json = "{\"authorization_endpoint\":\"https://example.com/auth\"," + + "\"token_endpoint\":\"https://example.com/token\"}"; + + OidcDiscoveryResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = OidcDiscoveryResponse.fromJson(reader); + } + + assertEquals("https://example.com/auth", response.authorizationEndpoint()); + assertEquals("https://example.com/token", response.tokenEndpoint()); + assertNull(response.deviceCodeEndpoint()); + assertNull(response.issuer()); + } + + @Test + void oidcDiscoveryResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"authorization_endpoint\":\"https://example.com/auth\"," + + "\"jwks_uri\":\"https://example.com/keys\"," + + "\"response_types_supported\":[\"code\"]}"; + + OidcDiscoveryResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = OidcDiscoveryResponse.fromJson(reader); + } + + assertEquals("https://example.com/auth", response.authorizationEndpoint()); + } + + @Test + void oidcDiscoveryResponse_toJson_doesNotIncludeIssuer() throws IOException { + // Note: toJson() intentionally omits the issuer field — this test documents that behavior + String json = "{\"authorization_endpoint\":\"https://example.com/auth\"," + + "\"token_endpoint\":\"https://example.com/token\"," + + "\"device_authorization_endpoint\":\"https://example.com/device\"," + + "\"issuer\":\"https://example.com/issuer\"}"; + + OidcDiscoveryResponse response; + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + response = OidcDiscoveryResponse.fromJson(reader); + } + + String output = writeToJson(response); + + assertTrue(output.contains("authorization_endpoint")); + assertTrue(output.contains("token_endpoint")); + assertTrue(output.contains("device_authorization_endpoint")); + // toJson does not write the issuer field + assertFalse(output.contains("\"issuer\"")); + } + + // ========== RequestedClaimAdditionalInfo ========== + + @Test + void requestedClaimAdditionalInfo_constructorAndGetters() { + List values = Arrays.asList("val1", "val2"); + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, "single", values); + + assertTrue(info.isEssential()); + assertEquals("single", info.getValue()); + assertEquals(Arrays.asList("val1", "val2"), info.getValues()); + } + + @Test + void requestedClaimAdditionalInfo_setters() { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, null, null); + + info.setEssential(true); + info.setValue("updated"); + info.setValues(Arrays.asList("a", "b")); + + assertTrue(info.isEssential()); + assertEquals("updated", info.getValue()); + assertEquals(Arrays.asList("a", "b"), info.getValues()); + } + + @Test + void requestedClaimAdditionalInfo_fromJson_allFields() throws IOException { + // Wrap in outer object since fromJson expects to be positioned in a field context + String json = "{\"essential\":true,\"value\":\"test-value\",\"values\":[\"v1\",\"v2\"]}"; + + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, null, null); + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + info = info.fromJson(reader); + } + + assertTrue(info.isEssential()); + assertEquals("test-value", info.getValue()); + assertEquals(Arrays.asList("v1", "v2"), info.getValues()); + } + + @Test + void requestedClaimAdditionalInfo_fromJson_essentialOnly() throws IOException { + String json = "{\"essential\":true}"; + + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, null, null); + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + info = info.fromJson(reader); + } + + assertTrue(info.isEssential()); + assertNull(info.getValue()); + assertNull(info.getValues()); + } + + @Test + void requestedClaimAdditionalInfo_toJson_essentialTrue() throws IOException { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, null, null); + + String output = writeToJson(info); + + assertTrue(output.contains("\"essential\":true")); + assertFalse(output.contains("\"value\"")); + assertFalse(output.contains("\"values\"")); + } + + @Test + void requestedClaimAdditionalInfo_toJson_essentialFalseOmitted() throws IOException { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, "v", null); + + String output = writeToJson(info); + + // essential=false is omitted from serialization + assertFalse(output.contains("essential")); + assertTrue(output.contains("\"value\":\"v\"")); + } + + @Test + void requestedClaimAdditionalInfo_toJson_withValues() throws IOException { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo( + false, null, Arrays.asList("x", "y")); + + String output = writeToJson(info); + + assertTrue(output.contains("\"values\"")); + assertTrue(output.contains("\"x\"")); + assertTrue(output.contains("\"y\"")); + } + + @Test + void requestedClaimAdditionalInfo_toJson_emptyValuesOmitted() throws IOException { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo( + false, null, Arrays.asList()); + + String output = writeToJson(info); + + // Empty list should be omitted (values != null but isEmpty()) + assertFalse(output.contains("values")); + } + + // ========== RequestedClaim ========== + + @Test + void requestedClaim_constructorAndGetter() { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, "val", null); + RequestedClaim claim = new RequestedClaim("acr", info); + + assertEquals("acr", claim.name); + assertEquals(info, claim.getRequestedClaimAdditionalInfo()); + } + + @Test + void requestedClaim_defaultConstructor() { + RequestedClaim claim = new RequestedClaim(); + + assertNull(claim.name); + assertNull(claim.getRequestedClaimAdditionalInfo()); + } + + @Test + void requestedClaim_setter() { + RequestedClaim claim = new RequestedClaim(); + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, null, null); + + claim.setRequestedClaimAdditionalInfo(info); + + assertEquals(info, claim.getRequestedClaimAdditionalInfo()); + } + + @Test + void requestedClaim_any_returnsCorrectMap() { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, "v", null); + RequestedClaim claim = new RequestedClaim("email", info); + + Map result = claim.any(); + + assertEquals(1, result.size()); + assertTrue(result.containsKey("email")); + assertEquals(info, result.get("email")); + } + + @Test + void requestedClaim_any_nullName() { + RequestedClaim claim = new RequestedClaim(null, null); + + Map result = claim.any(); + + assertEquals(1, result.size()); + assertTrue(result.containsKey(null)); + } + + @Test + void requestedClaim_toJson_withNameAndInfo_throwsDueToBadStringWrite() { + // Bug: RequestedClaim.toJson() calls writeString(name) inside an object context, + // which is invalid JSON (a raw string value where a field name is expected) + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, null, null); + RequestedClaim claim = new RequestedClaim("sub", info); + + assertThrows(IllegalStateException.class, () -> writeToJson(claim), + "toJson writes a raw string in object context, causing IllegalStateException"); + } + + @Test + void requestedClaim_toJson_nullNameAndInfo_writesEmptyObject() throws IOException { + RequestedClaim claim = new RequestedClaim(null, null); + + String output = writeToJson(claim); + + // When name or info is null, toJson skips writing the content + assertEquals("{}", output); + } + + // ========== Helper ========== + + private > String writeToJson(T serializable) + throws IOException { + java.io.StringWriter sw = new java.io.StringWriter(); + try (com.azure.json.JsonWriter writer = JsonProviders.createWriter(sw)) { + serializable.toJson(writer); + } + return sw.toString(); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java index e8bbc9e70..43214e88e 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java @@ -3,43 +3,140 @@ package com.microsoft.aad.msal4j; -import java.io.BufferedReader; -import java.io.FileReader; - +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.AfterAll; -import static org.junit.jupiter.api.Assertions.assertNotNull; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) +import static org.junit.jupiter.api.Assertions.*; + class WSTrustResponseTest { - @BeforeAll + private static final String AAD_TOKEN_ERROR_FILE = "/token-error.xml"; + + @BeforeEach void setup() { - System.setProperty("javax.xml.parsers.DocumentBuilderFactory", "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl"); + System.setProperty("javax.xml.parsers.DocumentBuilderFactory", + "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl"); } - @AfterAll + @AfterEach void cleanup() { System.clearProperty("javax.xml.parsers.DocumentBuilderFactory"); } @Test - void testWSTrustResponseParseSuccess() throws Exception { - StringBuilder sb = new StringBuilder(); - try (BufferedReader br = new BufferedReader(new FileReader((this - .getClass().getResource( - TestConfiguration.AAD_TOKEN_SUCCESS_FILE).getFile())))) { - String line = br.readLine(); + void parse_ValidToken_ReturnsResponseWithToken() throws Exception { + String xml = TestHelper.readResource(WSTrustResponseTest.class, + TestConfiguration.AAD_TOKEN_SUCCESS_FILE); + + WSTrustResponse response = WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13); - while (line != null) { - sb.append(line); - sb.append(System.lineSeparator()); - line = br.readLine(); - } - } - WSTrustResponse response = WSTrustResponse.parse(sb.toString(), WSTrustVersion.WSTRUST13); assertNotNull(response); + assertNotNull(response.getToken(), "Parsed response should contain a token"); + assertNotNull(response.getTokenType(), "Parsed response should contain a token type"); + assertFalse(response.isErrorFound(), "Successful parse should not have error"); + } + + @Test + void parse_ValidToken_TokenTypeIsSaml1() throws Exception { + String xml = TestHelper.readResource(WSTrustResponseTest.class, + TestConfiguration.AAD_TOKEN_SUCCESS_FILE); + + WSTrustResponse response = WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13); + + assertEquals(WSTrustResponse.SAML1_ASSERTION, response.getTokenType()); + assertFalse(response.isTokenSaml2(), "SAML 1.0 token should not be detected as SAML 2"); + } + + @Test + void parse_ValidToken_TokenContainsSamlAssertion() throws Exception { + String xml = TestHelper.readResource(WSTrustResponseTest.class, + TestConfiguration.AAD_TOKEN_SUCCESS_FILE); + + WSTrustResponse response = WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13); + + assertTrue(response.getToken().contains("saml:Assertion"), + "Token should contain SAML assertion XML"); + } + + @Test + void parse_ErrorResponse_ThrowsMsalServiceException() { + String xml = TestHelper.readResource(WSTrustResponseTest.class, AAD_TOKEN_ERROR_FILE); + + MsalServiceException ex = assertThrows(MsalServiceException.class, + () -> WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13)); + + assertEquals(AuthenticationErrorCode.WSTRUST_SERVICE_ERROR, ex.errorCode()); + assertTrue(ex.getMessage().contains("ErrorCode:"), + "Exception message should contain error code info"); + assertTrue(ex.getMessage().contains("FaultMessage:"), + "Exception message should contain fault message info"); + } + + @Test + void parse_ErrorResponse_ContainsReasonAndSubcode() { + String xml = TestHelper.readResource(WSTrustResponseTest.class, AAD_TOKEN_ERROR_FILE); + + MsalServiceException ex = assertThrows(MsalServiceException.class, + () -> WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13)); + + // The error XML has subcode "a:RequestFailed" which splits to "RequestFailed" + assertTrue(ex.getMessage().contains("RequestFailed"), + "Exception should contain parsed error subcode"); + assertTrue(ex.getMessage().contains("MSIS3127"), + "Exception should contain the SOAP fault reason text"); + } + + @Test + void parse_UndefinedVersion_ThrowsException() { + String xml = TestHelper.readResource(WSTrustResponseTest.class, + TestConfiguration.AAD_TOKEN_SUCCESS_FILE); + + // UNDEFINED version has empty XPaths which cause XPathExpressionException + assertThrows(Exception.class, + () -> WSTrustResponse.parse(xml, WSTrustVersion.UNDEFINED)); + } + + @Test + void isTokenSaml2_nonSaml1Type_returnsTrue() throws Exception { + String xml = TestHelper.readResource(WSTrustResponseTest.class, + TestConfiguration.AAD_TOKEN_SUCCESS_FILE); + WSTrustResponse response = WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13); + + // Token from test XML is SAML 1.0 + assertFalse(response.isTokenSaml2()); + + // Any non-SAML1 type should return true for isTokenSaml2 + // We can verify the constant value + assertEquals("urn:oasis:names:tc:SAML:1.0:assertion", WSTrustResponse.SAML1_ASSERTION); + } + + @Test + void parse_ValidToken_GettersReturnExpectedValues() throws Exception { + String xml = TestHelper.readResource(WSTrustResponseTest.class, + TestConfiguration.AAD_TOKEN_SUCCESS_FILE); + + WSTrustResponse response = WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13); + + assertFalse(response.isErrorFound()); + assertNull(response.getFaultMessage()); + assertNull(response.getErrorCode()); + assertNotNull(response.getToken()); + assertFalse(response.getToken().isEmpty()); + } + + @Test + void innerXml_extractsChildContent() throws Exception { + // innerXml is package-private static, test it directly with a simple XML node + javax.xml.parsers.DocumentBuilder builder = + javax.xml.parsers.DocumentBuilderFactory.newInstance().newDocumentBuilder(); + org.w3c.dom.Document doc = builder.parse( + new java.io.ByteArrayInputStream("text".getBytes())); + org.w3c.dom.Node root = doc.getDocumentElement(); + + String inner = WSTrustResponse.innerXml(root); + + assertTrue(inner.contains("text"), "innerXml should extract child content"); + assertTrue(inner.contains("child"), "innerXml should include child element tags"); } } From 6a048be1d657e0b32dd3ace125ee208fd1714071 Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 8 Jun 2026 14:41:33 -0700 Subject: [PATCH 19/24] Credentials tests --- .../msal4j/ClientCertificatePkcs12Test.java | 2 - .../aad/msal4j/ClientCertificateTest.java | 85 +++++- .../aad/msal4j/ClientCredentialTest.java | 251 ++++++++++++++++++ 3 files changed, 333 insertions(+), 5 deletions(-) diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificatePkcs12Test.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificatePkcs12Test.java index 5af514ac2..5dd5a8d83 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificatePkcs12Test.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificatePkcs12Test.java @@ -5,7 +5,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -21,7 +20,6 @@ import java.util.Collections; @ExtendWith(MockitoExtension.class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class ClientCertificatePkcs12Test { private KeyStoreSpi keyStoreSpi; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificateTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificateTest.java index ba9c34efd..3398e64e0 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificateTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificateTest.java @@ -5,9 +5,8 @@ import com.nimbusds.jwt.SignedJWT; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; - import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -20,11 +19,11 @@ import java.security.*; import java.security.cert.CertificateException; import java.security.interfaces.RSAPrivateKey; +import java.security.cert.X509Certificate; import java.util.*; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class ClientCertificateTest { @Test @@ -338,4 +337,84 @@ public List getEncodedPublicKeyCertificateChain() { return Collections.emptyList(); } } + + // ========== ClientCertificate: SHA-1 Hash ========== + + @Test + void testPublicCertificateHash_Sha1() throws Exception { + IClientCertificate cert = ClientCredentialFactory.createFromCertificate( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + String sha1Hash = cert.publicCertificateHash(); + + assertNotNull(sha1Hash, "SHA-1 hash should not be null"); + assertFalse(sha1Hash.isEmpty(), "SHA-1 hash should not be empty"); + // Base64-encoded SHA-1 is 28 characters + assertEquals(28, sha1Hash.length(), "Base64-encoded SHA-1 should be 28 chars"); + } + + @Test + void testPublicCertificateHash_Sha256DiffersFromSha1() throws Exception { + IClientCertificate cert = ClientCredentialFactory.createFromCertificate( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + String sha1Hash = cert.publicCertificateHash(); + String sha256Hash = cert.publicCertificateHash256(); + + assertNotEquals(sha1Hash, sha256Hash, + "SHA-1 and SHA-256 hashes should be different"); + } + + // ========== ClientCertificate: Certificate Chain Encoding ========== + + @Test + void testGetEncodedPublicKeyCertificateChain_singleCert() throws Exception { + ClientCertificate cert = ClientCertificate.create( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + List chain = cert.getEncodedPublicKeyCertificateChain(); + + assertNotNull(chain); + assertEquals(1, chain.size(), "Single cert should produce chain of length 1"); + assertFalse(chain.get(0).isEmpty(), "Encoded cert should not be empty"); + } + + @Test + void testGetEncodedPublicKeyCertificateChain_multiCert() throws Exception { + // Create a chain with the same cert repeated (simulates a CA chain) + List certChain = Arrays.asList( + TestHelper.getX509Cert(), TestHelper.getX509Cert()); + ClientCertificate cert = new ClientCertificate(TestHelper.getPrivateKey(), certChain); + + List chain = cert.getEncodedPublicKeyCertificateChain(); + + assertEquals(2, chain.size(), "Chain with 2 certs should produce 2 encoded entries"); + } + + // ========== ClientCertificate: getAssertion ========== + + @Test + void testGetAssertion_nullAuthority_throwsNullPointerException() { + ClientCertificate cert = ClientCertificate.create( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + assertThrows(NullPointerException.class, + () -> cert.getAssertion(null, "client-id", false)); + } + + @Test + void testGetAssertion_aadAuthority_usesSha256() throws Exception { + ClientCertificate cert = ClientCertificate.create( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + Authority authority = Authority.createAuthority( + new java.net.URL("https://login.microsoftonline.com/tenant/")); + + String assertion = cert.getAssertion(authority, "client-id", false); + + assertNotNull(assertion, "Assertion should not be null"); + // Verify it's a valid JWT (3 dot-separated parts) + String[] parts = assertion.split("\\."); + assertEquals(3, parts.length, "JWT assertion should have 3 parts"); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java index 5129d3a97..b4fbf27a6 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java @@ -7,10 +7,13 @@ import java.util.HashMap; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -204,4 +207,252 @@ void acquireTokenClientCredentials_Callback() { assertNotEquals(assertion1, assertion2, "First and second assertions should be different"); assertNotEquals(assertion2, assertion3, "Second and third assertions should be different"); } + + // ========== ClientAssertion: Context-Aware Provider ========== + + @Test + void clientAssertion_contextAwareProvider_returnsAssertion() { + Function provider = options -> "context-assertion"; + ClientAssertion assertion = new ClientAssertion(provider); + + AssertionRequestOptions options = new AssertionRequestOptions("client-id", "https://endpoint", "/fmi"); + assertEquals("context-assertion", assertion.assertion(options)); + } + + @Test + void clientAssertion_contextAwareProvider_nullReturnThrows() { + Function provider = options -> null; + ClientAssertion assertion = new ClientAssertion(provider); + + AssertionRequestOptions options = new AssertionRequestOptions("client-id", "https://endpoint", null); + MsalClientException ex = assertThrows(MsalClientException.class, + () -> assertion.assertion(options)); + assertEquals(AuthenticationErrorCode.INVALID_JWT, ex.errorCode()); + } + + @Test + void clientAssertion_contextAwareProvider_emptyReturnThrows() { + Function provider = options -> ""; + ClientAssertion assertion = new ClientAssertion(provider); + + MsalClientException ex = assertThrows(MsalClientException.class, + () -> assertion.assertion(new AssertionRequestOptions(null, null, null))); + assertEquals(AuthenticationErrorCode.INVALID_JWT, ex.errorCode()); + } + + @Test + void clientAssertion_contextAwareProvider_exceptionWrapped() { + Function provider = options -> { + throw new RuntimeException("provider failed"); + }; + ClientAssertion assertion = new ClientAssertion(provider); + + MsalClientException ex = assertThrows(MsalClientException.class, + () -> assertion.assertion(new AssertionRequestOptions(null, null, null))); + assertTrue(ex.getCause() instanceof RuntimeException); + } + + @Test + void clientAssertion_contextAwareProvider_noArgAssertionDelegatesToOptions() { + // assertion() with no args should delegate to assertion(options) with empty options + Function provider = options -> "from-no-arg"; + ClientAssertion assertion = new ClientAssertion(provider); + + assertEquals("from-no-arg", assertion.assertion()); + } + + @Test + void clientAssertion_isContextAware_trueForFunctionProvider() { + Function provider = options -> "test"; + ClientAssertion assertion = new ClientAssertion(provider); + assertTrue(assertion.isContextAware()); + } + + @Test + void clientAssertion_isContextAware_falseForCallable() { + ClientAssertion assertion = new ClientAssertion((Callable) () -> "test"); + assertFalse(assertion.isContextAware()); + } + + @Test + void clientAssertion_isContextAware_falseForStaticString() { + ClientAssertion assertion = new ClientAssertion("static-assertion"); + assertFalse(assertion.isContextAware()); + } + + // ========== ClientAssertion: Callable Error Paths ========== + + @Test + void clientAssertion_callableReturnsNull_throwsMsalClientException() { + ClientAssertion assertion = new ClientAssertion((Callable) () -> null); + + MsalClientException ex = assertThrows(MsalClientException.class, assertion::assertion); + assertEquals(AuthenticationErrorCode.INVALID_JWT, ex.errorCode()); + } + + @Test + void clientAssertion_callableReturnsEmpty_throwsMsalClientException() { + ClientAssertion assertion = new ClientAssertion((Callable) () -> ""); + + MsalClientException ex = assertThrows(MsalClientException.class, assertion::assertion); + assertEquals(AuthenticationErrorCode.INVALID_JWT, ex.errorCode()); + } + + @Test + void clientAssertion_callableThrowsException_wrappedInMsalClientException() { + ClientAssertion assertion = new ClientAssertion((Callable) () -> { + throw new Exception("callable failed"); + }); + + MsalClientException ex = assertThrows(MsalClientException.class, assertion::assertion); + assertTrue(ex.getCause().getMessage().contains("callable failed")); + } + + @Test + void clientAssertion_nullCallable_throwsNullPointerException() { + assertThrows(NullPointerException.class, + () -> new ClientAssertion((Callable) null)); + } + + @Test + void clientAssertion_nullFunction_throwsNullPointerException() { + assertThrows(NullPointerException.class, + () -> new ClientAssertion((Function) null)); + } + + // ========== ClientAssertion: Equals & HashCode ========== + + @Test + void clientAssertion_equals_sameStaticAssertion() { + ClientAssertion a = new ClientAssertion("test-jwt"); + ClientAssertion b = new ClientAssertion("test-jwt"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void clientAssertion_equals_differentStaticAssertion() { + ClientAssertion a = new ClientAssertion("jwt-1"); + ClientAssertion b = new ClientAssertion("jwt-2"); + + assertNotEquals(a, b); + } + + @Test + void clientAssertion_equals_sameCallable() { + Callable callable = () -> "test"; + ClientAssertion a = new ClientAssertion(callable); + ClientAssertion b = new ClientAssertion(callable); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void clientAssertion_equals_differentCallables() { + ClientAssertion a = new ClientAssertion((Callable) () -> "test"); + ClientAssertion b = new ClientAssertion((Callable) () -> "test"); + + // Different callable instances are compared by identity, so they're not equal + assertNotEquals(a, b); + } + + @Test + void clientAssertion_equals_sameContextAwareProvider() { + Function provider = opts -> "test"; + ClientAssertion a = new ClientAssertion(provider); + ClientAssertion b = new ClientAssertion(provider); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void clientAssertion_equals_differentContextAwareProviders() { + ClientAssertion a = new ClientAssertion((Function) opts -> "test"); + ClientAssertion b = new ClientAssertion((Function) opts -> "test"); + + assertNotEquals(a, b); + } + + @Test + void clientAssertion_equals_selfAndNull() { + ClientAssertion a = new ClientAssertion("jwt"); + assertEquals(a, a); + assertFalse(a.equals(null)); + assertFalse(a.equals("not-a-ClientAssertion")); + } + + // ========== ClientSecret: Equals & HashCode ========== + + @Test + void clientSecret_equals_sameSecret() { + ClientSecret a = new ClientSecret("secret-1"); + ClientSecret b = new ClientSecret("secret-1"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void clientSecret_equals_differentSecret() { + ClientSecret a = new ClientSecret("secret-1"); + ClientSecret b = new ClientSecret("secret-2"); + + assertNotEquals(a, b); + } + + @Test + void clientSecret_equals_selfAndNull() { + ClientSecret a = new ClientSecret("secret"); + assertEquals(a, a); + assertFalse(a.equals(null)); + assertFalse(a.equals("not-a-ClientSecret")); + } + + // ========== ClientCredentialFactory ========== + + @Test + void clientCredentialFactory_createFromCertificateChain_validInput() { + ClientCertificate cert = ClientCertificate.create( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + assertNotNull(cert); + assertNotNull(cert.privateKey()); + } + + @Test + void clientCredentialFactory_createFromCertificateChain_nullKey() { + assertThrows(IllegalArgumentException.class, + () -> ClientCredentialFactory.createFromCertificateChain(null, + Collections.singletonList(TestHelper.getX509Cert()))); + } + + @Test + void clientCredentialFactory_createFromCertificateChain_nullChain() { + assertThrows(IllegalArgumentException.class, + () -> ClientCredentialFactory.createFromCertificateChain( + TestHelper.getPrivateKey(), null)); + } + + @Test + void clientCredentialFactory_createFromCertificateChain_emptyChain() { + assertThrows(IllegalArgumentException.class, + () -> ClientCredentialFactory.createFromCertificateChain( + TestHelper.getPrivateKey(), Collections.emptyList())); + } + + @Test + void clientCredentialFactory_createFromCallback_nullCallable() { + assertThrows(NullPointerException.class, + () -> ClientCredentialFactory.createFromCallback((Callable) null)); + } + + @Test + void clientCredentialFactory_createFromCallback_nullFunction() { + assertThrows(NullPointerException.class, + () -> ClientCredentialFactory.createFromCallback( + (Function) null)); + } } From 5acce6c2976de9b74b1e075847443bfc27bac75d Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 8 Jun 2026 14:51:18 -0700 Subject: [PATCH 20/24] Instance discovery tests --- .../msal4j/InstanceDiscoveryParsingTest.java | 218 ++++++++++++++++ .../msal4j/ManagedIdentityParsingTest.java | 233 ++++++++++++++++++ .../SovereignCloudInstanceDiscoveryTest.java | 2 - 3 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java new file mode 100644 index 000000000..726c05801 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import com.azure.json.JsonProviders; +import com.azure.json.JsonReader; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.Arrays; +import java.util.HashSet; + +import static org.junit.jupiter.api.Assertions.*; + +class InstanceDiscoveryParsingTest { + + // ========== AadInstanceDiscoveryResponse ========== + + @Test + void aadInstanceDiscoveryResponse_fromJson_allFields() throws IOException { + String json = "{" + + "\"tenant_discovery_endpoint\":\"https://login.microsoftonline.com/tenant/.well-known/openid-configuration\"," + + "\"metadata\":[{" + + "\"preferred_network\":\"login.microsoftonline.com\"," + + "\"preferred_cache\":\"login.windows.net\"," + + "\"aliases\":[\"login.microsoftonline.com\",\"login.windows.net\"]" + + "}]," + + "\"error_description\":null," + + "\"error_codes\":null," + + "\"error\":null," + + "\"correlation_id\":\"corr-123\"" + + "}"; + + AadInstanceDiscoveryResponse response = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + assertEquals("https://login.microsoftonline.com/tenant/.well-known/openid-configuration", + response.tenantDiscoveryEndpoint()); + assertNotNull(response.metadata()); + assertEquals(1, response.metadata().size()); + assertEquals("login.microsoftonline.com", response.metadata().get(0).preferredNetwork()); + assertEquals("login.windows.net", response.metadata().get(0).preferredCache()); + assertEquals("corr-123", response.correlationId()); + } + + @Test + void aadInstanceDiscoveryResponse_fromJson_errorResponse() throws IOException { + String json = "{" + + "\"error\":\"invalid_instance\"," + + "\"error_description\":\"AADSTS50049: Unknown or invalid instance.\"," + + "\"error_codes\":[50049]," + + "\"correlation_id\":\"corr-err\"" + + "}"; + + AadInstanceDiscoveryResponse response = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + assertEquals("invalid_instance", response.error()); + assertEquals("AADSTS50049: Unknown or invalid instance.", response.errorDescription()); + assertNotNull(response.errorCodes()); + assertEquals(1, response.errorCodes().size()); + assertEquals(50049L, response.errorCodes().get(0).longValue()); + assertEquals("corr-err", response.correlationId()); + assertNull(response.tenantDiscoveryEndpoint()); + assertNull(response.metadata()); + } + + @Test + void aadInstanceDiscoveryResponse_fromJson_multipleMetadataEntries() throws IOException { + String json = "{" + + "\"tenant_discovery_endpoint\":\"https://endpoint\"," + + "\"metadata\":[" + + "{\"preferred_network\":\"login.microsoftonline.com\",\"preferred_cache\":\"login.windows.net\",\"aliases\":[\"login.microsoftonline.com\"]}," + + "{\"preferred_network\":\"login.chinacloudapi.cn\",\"preferred_cache\":\"login.chinacloudapi.cn\",\"aliases\":[\"login.chinacloudapi.cn\"]}" + + "]" + + "}"; + + AadInstanceDiscoveryResponse response = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + assertEquals(2, response.metadata().size()); + assertEquals("login.microsoftonline.com", response.metadata().get(0).preferredNetwork()); + assertEquals("login.chinacloudapi.cn", response.metadata().get(1).preferredNetwork()); + } + + @Test + void aadInstanceDiscoveryResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"error\":\"test\",\"api-version\":\"1.1\",\"extra\":true}"; + + AadInstanceDiscoveryResponse response = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + assertEquals("test", response.error()); + } + + @Test + void aadInstanceDiscoveryResponse_toJson_roundTrip() throws IOException { + String json = "{" + + "\"tenant_discovery_endpoint\":\"https://endpoint\"," + + "\"metadata\":[{" + + "\"preferred_network\":\"login.microsoftonline.com\"," + + "\"preferred_cache\":\"login.windows.net\"," + + "\"aliases\":[\"login.microsoftonline.com\"]" + + "}]," + + "\"error\":null," + + "\"correlation_id\":\"c-1\"" + + "}"; + + AadInstanceDiscoveryResponse original = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + String serialized = writeToJson(original); + + assertTrue(serialized.contains("tenant_discovery_endpoint")); + assertTrue(serialized.contains("login.microsoftonline.com")); + assertTrue(serialized.contains("correlation_id")); + } + + @Test + void aadInstanceDiscoveryResponse_getters_defaultNull() throws IOException { + String json = "{}"; + + AadInstanceDiscoveryResponse response = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + assertNull(response.tenantDiscoveryEndpoint()); + assertNull(response.metadata()); + assertNull(response.errorDescription()); + assertNull(response.errorCodes()); + assertNull(response.error()); + assertNull(response.correlationId()); + } + + // ========== InstanceDiscoveryMetadataEntry ========== + + @Test + void instanceDiscoveryMetadataEntry_fromJson_allFields() throws IOException { + String json = "{" + + "\"preferred_network\":\"login.microsoftonline.com\"," + + "\"preferred_cache\":\"login.windows.net\"," + + "\"aliases\":[\"login.microsoftonline.com\",\"login.windows.net\",\"login.microsoft.com\"]" + + "}"; + + InstanceDiscoveryMetadataEntry entry = parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); + + assertEquals("login.microsoftonline.com", entry.preferredNetwork()); + assertEquals("login.windows.net", entry.preferredCache()); + assertEquals(3, entry.aliases().size()); + assertTrue(entry.aliases().contains("login.microsoftonline.com")); + assertTrue(entry.aliases().contains("login.windows.net")); + assertTrue(entry.aliases().contains("login.microsoft.com")); + } + + @Test + void instanceDiscoveryMetadataEntry_constructorAndGetters() { + HashSet aliases = new HashSet<>(Arrays.asList("host1", "host2")); + InstanceDiscoveryMetadataEntry entry = new InstanceDiscoveryMetadataEntry( + "preferred-net", "preferred-cache", aliases); + + assertEquals("preferred-net", entry.preferredNetwork()); + assertEquals("preferred-cache", entry.preferredCache()); + assertEquals(aliases, entry.aliases()); + } + + @Test + void instanceDiscoveryMetadataEntry_defaultConstructor() { + InstanceDiscoveryMetadataEntry entry = new InstanceDiscoveryMetadataEntry(); + + assertNull(entry.preferredNetwork()); + assertNull(entry.preferredCache()); + assertNull(entry.aliases()); + } + + @Test + void instanceDiscoveryMetadataEntry_toJson_roundTrip() throws IOException { + String json = "{" + + "\"preferred_network\":\"login.microsoftonline.com\"," + + "\"preferred_cache\":\"login.windows.net\"," + + "\"aliases\":[\"login.microsoftonline.com\"]" + + "}"; + + InstanceDiscoveryMetadataEntry original = parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); + String serialized = writeToJson(original); + + InstanceDiscoveryMetadataEntry roundTripped = parseJson(serialized, InstanceDiscoveryMetadataEntry::fromJson); + + assertEquals(original.preferredNetwork(), roundTripped.preferredNetwork()); + assertEquals(original.preferredCache(), roundTripped.preferredCache()); + assertEquals(original.aliases(), roundTripped.aliases()); + } + + @Test + void instanceDiscoveryMetadataEntry_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"preferred_network\":\"host\",\"extra_field\":123}"; + + InstanceDiscoveryMetadataEntry entry = parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); + + assertEquals("host", entry.preferredNetwork()); + } + + // ========== Helpers ========== + + @FunctionalInterface + interface JsonParser { + T parse(JsonReader reader) throws IOException; + } + + private T parseJson(String json, JsonParser parser) throws IOException { + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + return parser.parse(reader); + } + } + + private > String writeToJson(T serializable) + throws IOException { + StringWriter sw = new StringWriter(); + try (com.azure.json.JsonWriter writer = JsonProviders.createWriter(sw)) { + serializable.toJson(writer); + } + return sw.toString(); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java new file mode 100644 index 000000000..d25b19978 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import com.azure.json.JsonProviders; +import com.azure.json.JsonReader; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; + +import static org.junit.jupiter.api.Assertions.*; + +class ManagedIdentityParsingTest { + + // ========== ManagedIdentityResponse ========== + + @Test + void managedIdentityResponse_fromJson_allFields() throws IOException { + String json = "{" + + "\"token_type\":\"Bearer\"," + + "\"access_token\":\"eyJ0eXAiOiJKV1QiLCJhbGciOi...\"," + + "\"expires_on\":\"1717430400\"," + + "\"resource\":\"https://management.azure.com/\"," + + "\"client_id\":\"00000000-0000-0000-0000-000000000000\"" + + "}"; + + ManagedIdentityResponse response = parseJson(json, ManagedIdentityResponse::fromJson); + + assertEquals("Bearer", response.getTokenType()); + assertEquals("eyJ0eXAiOiJKV1QiLCJhbGciOi...", response.getAccessToken()); + assertEquals("1717430400", response.getExpiresOn()); + assertEquals("https://management.azure.com/", response.getResource()); + assertEquals("00000000-0000-0000-0000-000000000000", response.getClientId()); + } + + @Test + void managedIdentityResponse_fromJson_partialFields() throws IOException { + String json = "{\"access_token\":\"token\",\"expires_on\":\"123456\"}"; + + ManagedIdentityResponse response = parseJson(json, ManagedIdentityResponse::fromJson); + + assertEquals("token", response.getAccessToken()); + assertEquals("123456", response.getExpiresOn()); + assertNull(response.getTokenType()); + assertNull(response.getResource()); + assertNull(response.getClientId()); + } + + @Test + void managedIdentityResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"access_token\":\"tok\",\"extra_field\":\"ignored\",\"nested\":{\"a\":1}}"; + + ManagedIdentityResponse response = parseJson(json, ManagedIdentityResponse::fromJson); + + assertEquals("tok", response.getAccessToken()); + } + + @Test + void managedIdentityResponse_toJson_allFields() throws IOException { + ManagedIdentityResponse response = new ManagedIdentityResponse(); + response.tokenType = "Bearer"; + response.accessToken = "test-token"; + response.expiresOn = "9999999"; + response.resource = "https://vault.azure.net/"; + response.clientId = "client-123"; + + String output = writeToJson(response); + + assertTrue(output.contains("\"token_type\":\"Bearer\"")); + assertTrue(output.contains("\"access_token\":\"test-token\"")); + assertTrue(output.contains("\"expires_on\":\"9999999\"")); + assertTrue(output.contains("\"resource\":\"https://vault.azure.net/\"")); + assertTrue(output.contains("\"client_id\":\"client-123\"")); + } + + @Test + void managedIdentityResponse_toJson_roundTrip() throws IOException { + String json = "{" + + "\"token_type\":\"Bearer\"," + + "\"access_token\":\"round-trip-token\"," + + "\"expires_on\":\"12345\"," + + "\"resource\":\"https://api.example.com/\"," + + "\"client_id\":\"cid-456\"" + + "}"; + + ManagedIdentityResponse original = parseJson(json, ManagedIdentityResponse::fromJson); + String serialized = writeToJson(original); + ManagedIdentityResponse roundTripped = parseJson(serialized, ManagedIdentityResponse::fromJson); + + assertEquals(original.getTokenType(), roundTripped.getTokenType()); + assertEquals(original.getAccessToken(), roundTripped.getAccessToken()); + assertEquals(original.getExpiresOn(), roundTripped.getExpiresOn()); + assertEquals(original.getResource(), roundTripped.getResource()); + assertEquals(original.getClientId(), roundTripped.getClientId()); + } + + @Test + void managedIdentityResponse_defaultConstructor_allNull() { + ManagedIdentityResponse response = new ManagedIdentityResponse(); + + assertNull(response.getTokenType()); + assertNull(response.getAccessToken()); + assertNull(response.getExpiresOn()); + assertNull(response.getResource()); + assertNull(response.getClientId()); + } + + // ========== ManagedIdentityErrorResponse ========== + + @Test + void managedIdentityErrorResponse_fromJson_simpleError() throws IOException { + String json = "{" + + "\"error\":\"invalid_resource\"," + + "\"error_description\":\"The resource requested is invalid.\"," + + "\"message\":\"Identity not found\"," + + "\"correlationId\":\"corr-789\"" + + "}"; + + ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + + assertEquals("invalid_resource", response.getError()); + assertEquals("The resource requested is invalid.", response.getErrorDescription()); + assertEquals("Identity not found", response.getMessage()); + assertEquals("corr-789", response.getCorrelationId()); + } + + @Test + void managedIdentityErrorResponse_fromJson_nestedErrorObject() throws IOException { + // Some MI endpoints return error as a nested JSON object with code/message + String json = "{" + + "\"error\":{\"code\":\"ManagedIdentityCredential\",\"message\":\"Managed identity unavailable\"}," + + "\"correlationId\":\"corr-nested\"" + + "}"; + + ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + + assertEquals("ManagedIdentityCredential", response.getError()); + assertEquals("Managed identity unavailable", response.getMessage()); + assertEquals("corr-nested", response.getCorrelationId()); + } + + @Test + void managedIdentityErrorResponse_fromJson_errorAsString() throws IOException { + String json = "{\"error\":\"unauthorized_client\"}"; + + ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + + assertEquals("unauthorized_client", response.getError()); + } + + @Test + void managedIdentityErrorResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"error\":\"test\",\"unknown_field\":42,\"nested_unknown\":{\"a\":1}}"; + + ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + + assertEquals("test", response.getError()); + } + + @Test + void managedIdentityErrorResponse_fromJson_emptyObject() throws IOException { + String json = "{}"; + + ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + + assertNull(response.getError()); + assertNull(response.getErrorDescription()); + assertNull(response.getMessage()); + assertNull(response.getCorrelationId()); + } + + @Test + void managedIdentityErrorResponse_toJson_allFields() throws IOException { + String json = "{" + + "\"message\":\"Identity not found\"," + + "\"correlationId\":\"c-1\"," + + "\"error\":\"not_found\"," + + "\"error_description\":\"desc\"" + + "}"; + + ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + String output = writeToJson(response); + + assertTrue(output.contains("\"message\":\"Identity not found\"")); + assertTrue(output.contains("\"correlationId\":\"c-1\"")); + assertTrue(output.contains("\"error\":\"not_found\"")); + assertTrue(output.contains("\"error_description\":\"desc\"")); + } + + @Test + void managedIdentityErrorResponse_toJson_roundTrip() throws IOException { + String json = "{" + + "\"message\":\"msg\"," + + "\"correlationId\":\"corr\"," + + "\"error\":\"err\"," + + "\"error_description\":\"desc\"" + + "}"; + + ManagedIdentityErrorResponse original = parseJson(json, ManagedIdentityErrorResponse::fromJson); + String serialized = writeToJson(original); + ManagedIdentityErrorResponse roundTripped = parseJson(serialized, ManagedIdentityErrorResponse::fromJson); + + assertEquals(original.getError(), roundTripped.getError()); + assertEquals(original.getErrorDescription(), roundTripped.getErrorDescription()); + assertEquals(original.getMessage(), roundTripped.getMessage()); + assertEquals(original.getCorrelationId(), roundTripped.getCorrelationId()); + } + + // ========== Helpers ========== + + @FunctionalInterface + interface JsonParser { + T parse(JsonReader reader) throws IOException; + } + + private T parseJson(String json, JsonParser parser) throws IOException { + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + return parser.parse(reader); + } + } + + private > String writeToJson(T serializable) + throws IOException { + StringWriter sw = new StringWriter(); + try (com.azure.json.JsonWriter writer = JsonProviders.createWriter(sw)) { + serializable.toJson(writer); + } + return sw.toString(); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java index 4f45515b9..9b802289a 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java @@ -5,7 +5,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import org.mockito.ArgumentCaptor; import java.util.ArrayList; @@ -24,7 +23,6 @@ * mock only the HTTP layer, and verify that all HTTP requests are routed to the * correct sovereign host — not to login.microsoftonline.com or any other host. */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class SovereignCloudInstanceDiscoveryTest { private static final String SOVEREIGN_HOST = "login.sovcloud-identity.fr"; From bc4929231ddeb5e7e2fd05ad8af6b699c0089077 Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 8 Jun 2026 15:04:01 -0700 Subject: [PATCH 21/24] WSTrust tests --- .../aad/msal4j/WSTrustRequestTest.java | 60 ++- .../aad/msal4j/WsTrustFederationTest.java | 347 ++++++++++++++++++ 2 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java index 31a953e29..53d080a59 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java @@ -5,11 +5,9 @@ import org.apache.commons.text.StringEscapeUtils; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class WSTrustRequestTest { @Test @@ -54,4 +52,62 @@ void escapeXMLElementDataTest() { assertEquals(StringEscapeUtils.escapeXml10(DATA_TO_ESCAPE), WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE)); } + + @Test + void buildMessage_wsTrust13_usesTrust13Namespaces() { + String msg = WSTrustRequest.buildMessage("https://adfs.example.com", "user", + "pass", WSTrustVersion.WSTRUST13, "urn:federation:MicrosoftOnline").toString(); + + assertTrue(msg.contains("http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Issue"), + "WSTrust 1.3 should use trust/200512 action"); + assertTrue(msg.contains("xmlns:trust='http://docs.oasis-open.org/ws-sx/ws-trust/200512'"), + "WSTrust 1.3 should use trust/200512 namespace"); + assertTrue(msg.contains("http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer"), + "WSTrust 1.3 should use trust/200512 Bearer key type"); + } + + @Test + void buildMessage_wsTrust2005_uses2005Namespaces() { + String msg = WSTrustRequest.buildMessage("https://adfs.example.com", "user", + "pass", WSTrustVersion.WSTRUST2005, "urn:federation:MicrosoftOnline").toString(); + + assertTrue(msg.contains("http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue"), + "WSTrust 2005 should use ws/2005 action"); + assertTrue(msg.contains("xmlns:trust='http://schemas.xmlsoap.org/ws/2005/02/trust'"), + "WSTrust 2005 should use ws/2005 namespace"); + assertTrue(msg.contains("http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey"), + "WSTrust 2005 should use NoProofKey"); + } + + @Test + void buildMessage_withCredentials_includesSecurityHeader() { + String msg = WSTrustRequest.buildMessage("address", "testuser", + "testpass", WSTrustVersion.WSTRUST13, null).toString(); + + assertTrue(msg.contains("testuser")); + assertTrue(msg.contains("testpass")); + assertTrue(msg.contains("")); + assertTrue(msg.contains("")); + } + + @Test + void buildMessage_withSpecialCharsInCredentials_escapesXml() { + String msg = WSTrustRequest.buildMessage("address", "user&<>", + "pass'\"", WSTrustVersion.WSTRUST13, null).toString(); + + assertTrue(msg.contains("user&<>"), "Username should be XML-escaped"); + assertTrue(msg.contains("pass'""), "Password should be XML-escaped"); + } + + @Test + void escapeXMLElementData_noSpecialChars_returnsUnchanged() { + assertEquals("simple text 123", WSTrustRequest.escapeXMLElementData("simple text 123")); + } + + @Test + void escapeXMLElementData_emptyString() { + assertEquals("", WSTrustRequest.escapeXMLElementData("")); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java new file mode 100644 index 000000000..3213234f5 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import com.azure.json.JsonProviders; +import com.azure.json.JsonReader; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +class WsTrustFederationTest { + + // ========== Credential ========== + + @Test + void credential_fromJson_allFields() throws IOException { + String json = "{" + + "\"home_account_id\":\"uid.utid\"," + + "\"environment\":\"login.microsoftonline.com\"," + + "\"client_id\":\"client-123\"," + + "\"secret\":\"secret-token\"," + + "\"user_assertion_hash\":\"hash-abc\"" + + "}"; + + Credential cred = parseJson(json, Credential::fromJson); + + assertEquals("uid.utid", cred.homeAccountId()); + assertEquals("login.microsoftonline.com", cred.environment()); + assertEquals("client-123", cred.clientId()); + assertEquals("secret-token", cred.secret()); + assertEquals("hash-abc", cred.userAssertionHash()); + } + + @Test + void credential_fromJson_partialFields() throws IOException { + String json = "{\"home_account_id\":\"uid\",\"environment\":\"env\"}"; + + Credential cred = parseJson(json, Credential::fromJson); + + assertEquals("uid", cred.homeAccountId()); + assertEquals("env", cred.environment()); + assertNull(cred.clientId()); + assertNull(cred.secret()); + assertNull(cred.userAssertionHash()); + } + + @Test + void credential_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"home_account_id\":\"uid\",\"extra\":123}"; + + Credential cred = parseJson(json, Credential::fromJson); + + assertEquals("uid", cred.homeAccountId()); + } + + @Test + void credential_settersAndGetters() { + Credential cred = new Credential(); + + cred.homeAccountId("home-1"); + cred.environment("env-1"); + cred.clientId("client-1"); + cred.secret("secret-1"); + cred.userAssertionHash("hash-1"); + + assertEquals("home-1", cred.homeAccountId()); + assertEquals("env-1", cred.environment()); + assertEquals("client-1", cred.clientId()); + assertEquals("secret-1", cred.secret()); + assertEquals("hash-1", cred.userAssertionHash()); + } + + @Test + void credential_toJson_allFields() throws IOException { + Credential cred = new Credential(); + cred.homeAccountId("uid.utid"); + cred.environment("login.microsoftonline.com"); + cred.clientId("cid"); + cred.secret("sec"); + cred.userAssertionHash("hash"); + + String output = writeToJson(cred); + + assertTrue(output.contains("\"home_account_id\":\"uid.utid\"")); + assertTrue(output.contains("\"environment\":\"login.microsoftonline.com\"")); + assertTrue(output.contains("\"client_id\":\"cid\"")); + assertTrue(output.contains("\"secret\":\"sec\"")); + assertTrue(output.contains("\"user_assertion_hash\":\"hash\"")); + } + + @Test + void credential_toJson_roundTrip() throws IOException { + String json = "{" + + "\"home_account_id\":\"uid\"," + + "\"environment\":\"env\"," + + "\"client_id\":\"cid\"," + + "\"secret\":\"sec\"," + + "\"user_assertion_hash\":\"hash\"" + + "}"; + + Credential original = parseJson(json, Credential::fromJson); + String serialized = writeToJson(original); + Credential roundTripped = parseJson(serialized, Credential::fromJson); + + assertEquals(original.homeAccountId(), roundTripped.homeAccountId()); + assertEquals(original.environment(), roundTripped.environment()); + assertEquals(original.clientId(), roundTripped.clientId()); + assertEquals(original.secret(), roundTripped.secret()); + assertEquals(original.userAssertionHash(), roundTripped.userAssertionHash()); + } + + // ========== BindingPolicy ========== + + @Test + void bindingPolicy_singleArgConstructor() { + BindingPolicy policy = new BindingPolicy("policy-value"); + + assertEquals("policy-value", policy.getValue()); + assertNull(policy.getUrl()); + assertNull(policy.getVersion()); + } + + @Test + void bindingPolicy_twoArgConstructor() { + BindingPolicy policy = new BindingPolicy("https://adfs.example.com/trust", WSTrustVersion.WSTRUST13); + + assertEquals("https://adfs.example.com/trust", policy.getUrl()); + assertEquals(WSTrustVersion.WSTRUST13, policy.getVersion()); + assertNull(policy.getValue()); + } + + @Test + void bindingPolicy_settersAndGetters() { + BindingPolicy policy = new BindingPolicy("initial"); + + policy.setValue("updated-value"); + policy.setUrl("https://new-url"); + policy.setVersion(WSTrustVersion.WSTRUST2005); + + assertEquals("updated-value", policy.getValue()); + assertEquals("https://new-url", policy.getUrl()); + assertEquals(WSTrustVersion.WSTRUST2005, policy.getVersion()); + } + + @Test + void bindingPolicy_versionUndefined() { + BindingPolicy policy = new BindingPolicy("https://url", WSTrustVersion.UNDEFINED); + + assertEquals(WSTrustVersion.UNDEFINED, policy.getVersion()); + } + + // ========== IntegratedWindowsAuthorizationGrant ========== + + @Test + void integratedWindowsAuthGrant_constructor() { + IntegratedWindowsAuthorizationGrant grant = new IntegratedWindowsAuthorizationGrant( + Collections.singleton("openid"), "user@domain.com", null); + + assertEquals("user@domain.com", grant.getUserName()); + assertNull(grant.toParameters()); + } + + @Test + void integratedWindowsAuthGrant_withClaims() { + ClaimsRequest claims = new ClaimsRequest(); + IntegratedWindowsAuthorizationGrant grant = new IntegratedWindowsAuthorizationGrant( + Collections.singleton("profile"), "admin@corp.net", claims); + + assertEquals("admin@corp.net", grant.getUserName()); + } + + // ========== IdToken ========== + + @Test + void idToken_fromJson_allFields() throws IOException { + String json = "{" + + "\"iss\":\"https://login.microsoftonline.com/tenant/v2.0\"," + + "\"sub\":\"sub-123\"," + + "\"aud\":\"client-id\"," + + "\"exp\":1717430400," + + "\"iat\":1717426800," + + "\"nbf\":1717426800," + + "\"name\":\"Test User\"," + + "\"preferred_username\":\"testuser@example.com\"," + + "\"oid\":\"oid-456\"," + + "\"tid\":\"tid-789\"," + + "\"upn\":\"testuser@example.com\"," + + "\"unique_name\":\"testuser\"" + + "}"; + + IdToken idToken = parseJson(json, IdToken::fromJson); + + assertEquals("https://login.microsoftonline.com/tenant/v2.0", idToken.issuer); + assertEquals("sub-123", idToken.subject); + assertEquals("client-id", idToken.audience); + assertEquals(1717430400L, idToken.expirationTime.longValue()); + assertEquals(1717426800L, idToken.issuedAt.longValue()); + assertEquals(1717426800L, idToken.notBefore.longValue()); + assertEquals("Test User", idToken.name); + assertEquals("testuser@example.com", idToken.preferredUsername); + assertEquals("oid-456", idToken.objectIdentifier); + assertEquals("tid-789", idToken.tenantIdentifier); + assertEquals("testuser@example.com", idToken.upn); + assertEquals("testuser", idToken.uniqueName); + } + + @Test + void idToken_fromJson_partialFields() throws IOException { + String json = "{\"iss\":\"issuer\",\"sub\":\"subject\",\"aud\":\"audience\"}"; + + IdToken idToken = parseJson(json, IdToken::fromJson); + + assertEquals("issuer", idToken.issuer); + assertEquals("subject", idToken.subject); + assertEquals("audience", idToken.audience); + assertNull(idToken.expirationTime); + assertNull(idToken.issuedAt); + assertNull(idToken.notBefore); + assertNull(idToken.name); + assertNull(idToken.preferredUsername); + assertNull(idToken.objectIdentifier); + assertNull(idToken.tenantIdentifier); + assertNull(idToken.upn); + assertNull(idToken.uniqueName); + } + + @Test + void idToken_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"iss\":\"issuer\",\"nonce\":\"abc\",\"email\":\"user@test.com\"}"; + + IdToken idToken = parseJson(json, IdToken::fromJson); + + assertEquals("issuer", idToken.issuer); + } + + @Test + void idToken_toJson_allFields() throws IOException { + IdToken idToken = new IdToken(); + idToken.issuer = "iss"; + idToken.subject = "sub"; + idToken.audience = "aud"; + idToken.expirationTime = 99999L; + idToken.issuedAt = 11111L; + idToken.notBefore = 11111L; + idToken.name = "User Name"; + idToken.preferredUsername = "user@example.com"; + idToken.objectIdentifier = "oid"; + idToken.tenantIdentifier = "tid"; + idToken.upn = "user@example.com"; + idToken.uniqueName = "unique"; + + String output = writeToJson(idToken); + + assertTrue(output.contains("\"iss\":\"iss\"")); + assertTrue(output.contains("\"sub\":\"sub\"")); + assertTrue(output.contains("\"aud\":\"aud\"")); + assertTrue(output.contains("\"name\":\"User Name\"")); + assertTrue(output.contains("\"preferred_username\":\"user@example.com\"")); + assertTrue(output.contains("\"oid\":\"oid\"")); + assertTrue(output.contains("\"tid\":\"tid\"")); + assertTrue(output.contains("\"upn\":\"user@example.com\"")); + assertTrue(output.contains("\"unique_name\":\"unique\"")); + } + + @Test + void idToken_toJson_roundTrip() throws IOException { + String json = "{" + + "\"iss\":\"issuer\"," + + "\"sub\":\"subject\"," + + "\"aud\":\"audience\"," + + "\"exp\":1234567890," + + "\"iat\":1234567800," + + "\"nbf\":1234567800," + + "\"name\":\"Test\"," + + "\"preferred_username\":\"test@test.com\"," + + "\"oid\":\"oid-1\"," + + "\"tid\":\"tid-1\"," + + "\"upn\":\"upn@test.com\"," + + "\"unique_name\":\"unique-1\"" + + "}"; + + IdToken original = parseJson(json, IdToken::fromJson); + String serialized = writeToJson(original); + IdToken roundTripped = parseJson(serialized, IdToken::fromJson); + + assertEquals(original.issuer, roundTripped.issuer); + assertEquals(original.subject, roundTripped.subject); + assertEquals(original.audience, roundTripped.audience); + assertEquals(original.expirationTime, roundTripped.expirationTime); + assertEquals(original.name, roundTripped.name); + assertEquals(original.preferredUsername, roundTripped.preferredUsername); + assertEquals(original.objectIdentifier, roundTripped.objectIdentifier); + assertEquals(original.tenantIdentifier, roundTripped.tenantIdentifier); + assertEquals(original.upn, roundTripped.upn); + assertEquals(original.uniqueName, roundTripped.uniqueName); + } + + // ========== WSTrustVersion ========== + + @Test + void wsTrustVersion_pathValues() { + assertFalse(WSTrustVersion.WSTRUST13.responseTokenTypePath().isEmpty()); + assertFalse(WSTrustVersion.WSTRUST13.responseSecurityTokenPath().isEmpty()); + + assertFalse(WSTrustVersion.WSTRUST2005.responseTokenTypePath().isEmpty()); + assertFalse(WSTrustVersion.WSTRUST2005.responseSecurityTokenPath().isEmpty()); + + assertTrue(WSTrustVersion.UNDEFINED.responseTokenTypePath().isEmpty()); + assertTrue(WSTrustVersion.UNDEFINED.responseSecurityTokenPath().isEmpty()); + } + + @Test + void wsTrustVersion_wsTrust13_containsCorrectPaths() { + assertTrue(WSTrustVersion.WSTRUST13.responseTokenTypePath() + .contains("RequestSecurityTokenResponseCollection")); + assertTrue(WSTrustVersion.WSTRUST13.responseSecurityTokenPath() + .contains("RequestedSecurityToken")); + } + + // ========== Helpers ========== + + @FunctionalInterface + interface JsonParser { + T parse(JsonReader reader) throws IOException; + } + + private T parseJson(String json, JsonParser parser) throws IOException { + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + return parser.parse(reader); + } + } + + private > String writeToJson(T serializable) + throws IOException { + StringWriter sw = new StringWriter(); + try (com.azure.json.JsonWriter writer = JsonProviders.createWriter(sw)) { + serializable.toJson(writer); + } + return sw.toString(); + } +} From 2dcf8db3b4878b4245f449ac18a9082fa0e24ea2 Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 8 Jun 2026 15:18:01 -0700 Subject: [PATCH 22/24] Minor fixes --- .../msal4j/InstanceDiscoveryParsingTest.java | 50 ++----- .../msal4j/ManagedIdentityParsingTest.java | 60 +++------ .../aad/msal4j/ResponseParsingTest.java | 125 +++++------------- .../com/microsoft/aad/msal4j/TestHelper.java | 35 +++++ .../aad/msal4j/WSTrustResponseTest.java | 88 +++++------- .../aad/msal4j/WsTrustFederationTest.java | 54 ++------ 6 files changed, 140 insertions(+), 272 deletions(-) diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java index 726c05801..80ee135da 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java @@ -3,13 +3,9 @@ package com.microsoft.aad.msal4j; -import com.azure.json.JsonProviders; -import com.azure.json.JsonReader; import org.junit.jupiter.api.Test; import java.io.IOException; -import java.io.StringReader; -import java.io.StringWriter; import java.util.Arrays; import java.util.HashSet; @@ -34,7 +30,7 @@ void aadInstanceDiscoveryResponse_fromJson_allFields() throws IOException { + "\"correlation_id\":\"corr-123\"" + "}"; - AadInstanceDiscoveryResponse response = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + AadInstanceDiscoveryResponse response = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); assertEquals("https://login.microsoftonline.com/tenant/.well-known/openid-configuration", response.tenantDiscoveryEndpoint()); @@ -54,7 +50,7 @@ void aadInstanceDiscoveryResponse_fromJson_errorResponse() throws IOException { + "\"correlation_id\":\"corr-err\"" + "}"; - AadInstanceDiscoveryResponse response = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + AadInstanceDiscoveryResponse response = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); assertEquals("invalid_instance", response.error()); assertEquals("AADSTS50049: Unknown or invalid instance.", response.errorDescription()); @@ -76,7 +72,7 @@ void aadInstanceDiscoveryResponse_fromJson_multipleMetadataEntries() throws IOEx + "]" + "}"; - AadInstanceDiscoveryResponse response = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + AadInstanceDiscoveryResponse response = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); assertEquals(2, response.metadata().size()); assertEquals("login.microsoftonline.com", response.metadata().get(0).preferredNetwork()); @@ -87,7 +83,7 @@ void aadInstanceDiscoveryResponse_fromJson_multipleMetadataEntries() throws IOEx void aadInstanceDiscoveryResponse_fromJson_unknownFieldsSkipped() throws IOException { String json = "{\"error\":\"test\",\"api-version\":\"1.1\",\"extra\":true}"; - AadInstanceDiscoveryResponse response = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + AadInstanceDiscoveryResponse response = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); assertEquals("test", response.error()); } @@ -105,8 +101,8 @@ void aadInstanceDiscoveryResponse_toJson_roundTrip() throws IOException { + "\"correlation_id\":\"c-1\"" + "}"; - AadInstanceDiscoveryResponse original = parseJson(json, AadInstanceDiscoveryResponse::fromJson); - String serialized = writeToJson(original); + AadInstanceDiscoveryResponse original = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); + String serialized = TestHelper.writeToJson(original); assertTrue(serialized.contains("tenant_discovery_endpoint")); assertTrue(serialized.contains("login.microsoftonline.com")); @@ -117,7 +113,7 @@ void aadInstanceDiscoveryResponse_toJson_roundTrip() throws IOException { void aadInstanceDiscoveryResponse_getters_defaultNull() throws IOException { String json = "{}"; - AadInstanceDiscoveryResponse response = parseJson(json, AadInstanceDiscoveryResponse::fromJson); + AadInstanceDiscoveryResponse response = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); assertNull(response.tenantDiscoveryEndpoint()); assertNull(response.metadata()); @@ -137,7 +133,7 @@ void instanceDiscoveryMetadataEntry_fromJson_allFields() throws IOException { + "\"aliases\":[\"login.microsoftonline.com\",\"login.windows.net\",\"login.microsoft.com\"]" + "}"; - InstanceDiscoveryMetadataEntry entry = parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); + InstanceDiscoveryMetadataEntry entry = TestHelper.parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); assertEquals("login.microsoftonline.com", entry.preferredNetwork()); assertEquals("login.windows.net", entry.preferredCache()); @@ -175,10 +171,10 @@ void instanceDiscoveryMetadataEntry_toJson_roundTrip() throws IOException { + "\"aliases\":[\"login.microsoftonline.com\"]" + "}"; - InstanceDiscoveryMetadataEntry original = parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); - String serialized = writeToJson(original); + InstanceDiscoveryMetadataEntry original = TestHelper.parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); + String serialized = TestHelper.writeToJson(original); - InstanceDiscoveryMetadataEntry roundTripped = parseJson(serialized, InstanceDiscoveryMetadataEntry::fromJson); + InstanceDiscoveryMetadataEntry roundTripped = TestHelper.parseJson(serialized, InstanceDiscoveryMetadataEntry::fromJson); assertEquals(original.preferredNetwork(), roundTripped.preferredNetwork()); assertEquals(original.preferredCache(), roundTripped.preferredCache()); @@ -189,30 +185,8 @@ void instanceDiscoveryMetadataEntry_toJson_roundTrip() throws IOException { void instanceDiscoveryMetadataEntry_fromJson_unknownFieldsSkipped() throws IOException { String json = "{\"preferred_network\":\"host\",\"extra_field\":123}"; - InstanceDiscoveryMetadataEntry entry = parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); + InstanceDiscoveryMetadataEntry entry = TestHelper.parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); assertEquals("host", entry.preferredNetwork()); } - - // ========== Helpers ========== - - @FunctionalInterface - interface JsonParser { - T parse(JsonReader reader) throws IOException; - } - - private T parseJson(String json, JsonParser parser) throws IOException { - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - return parser.parse(reader); - } - } - - private > String writeToJson(T serializable) - throws IOException { - StringWriter sw = new StringWriter(); - try (com.azure.json.JsonWriter writer = JsonProviders.createWriter(sw)) { - serializable.toJson(writer); - } - return sw.toString(); - } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java index d25b19978..ea39bb27c 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java @@ -3,13 +3,9 @@ package com.microsoft.aad.msal4j; -import com.azure.json.JsonProviders; -import com.azure.json.JsonReader; import org.junit.jupiter.api.Test; import java.io.IOException; -import java.io.StringReader; -import java.io.StringWriter; import static org.junit.jupiter.api.Assertions.*; @@ -27,7 +23,7 @@ void managedIdentityResponse_fromJson_allFields() throws IOException { + "\"client_id\":\"00000000-0000-0000-0000-000000000000\"" + "}"; - ManagedIdentityResponse response = parseJson(json, ManagedIdentityResponse::fromJson); + ManagedIdentityResponse response = TestHelper.parseJson(json, ManagedIdentityResponse::fromJson); assertEquals("Bearer", response.getTokenType()); assertEquals("eyJ0eXAiOiJKV1QiLCJhbGciOi...", response.getAccessToken()); @@ -40,7 +36,7 @@ void managedIdentityResponse_fromJson_allFields() throws IOException { void managedIdentityResponse_fromJson_partialFields() throws IOException { String json = "{\"access_token\":\"token\",\"expires_on\":\"123456\"}"; - ManagedIdentityResponse response = parseJson(json, ManagedIdentityResponse::fromJson); + ManagedIdentityResponse response = TestHelper.parseJson(json, ManagedIdentityResponse::fromJson); assertEquals("token", response.getAccessToken()); assertEquals("123456", response.getExpiresOn()); @@ -53,7 +49,7 @@ void managedIdentityResponse_fromJson_partialFields() throws IOException { void managedIdentityResponse_fromJson_unknownFieldsSkipped() throws IOException { String json = "{\"access_token\":\"tok\",\"extra_field\":\"ignored\",\"nested\":{\"a\":1}}"; - ManagedIdentityResponse response = parseJson(json, ManagedIdentityResponse::fromJson); + ManagedIdentityResponse response = TestHelper.parseJson(json, ManagedIdentityResponse::fromJson); assertEquals("tok", response.getAccessToken()); } @@ -67,7 +63,7 @@ void managedIdentityResponse_toJson_allFields() throws IOException { response.resource = "https://vault.azure.net/"; response.clientId = "client-123"; - String output = writeToJson(response); + String output = TestHelper.writeToJson(response); assertTrue(output.contains("\"token_type\":\"Bearer\"")); assertTrue(output.contains("\"access_token\":\"test-token\"")); @@ -86,9 +82,9 @@ void managedIdentityResponse_toJson_roundTrip() throws IOException { + "\"client_id\":\"cid-456\"" + "}"; - ManagedIdentityResponse original = parseJson(json, ManagedIdentityResponse::fromJson); - String serialized = writeToJson(original); - ManagedIdentityResponse roundTripped = parseJson(serialized, ManagedIdentityResponse::fromJson); + ManagedIdentityResponse original = TestHelper.parseJson(json, ManagedIdentityResponse::fromJson); + String serialized = TestHelper.writeToJson(original); + ManagedIdentityResponse roundTripped = TestHelper.parseJson(serialized, ManagedIdentityResponse::fromJson); assertEquals(original.getTokenType(), roundTripped.getTokenType()); assertEquals(original.getAccessToken(), roundTripped.getAccessToken()); @@ -119,7 +115,7 @@ void managedIdentityErrorResponse_fromJson_simpleError() throws IOException { + "\"correlationId\":\"corr-789\"" + "}"; - ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); assertEquals("invalid_resource", response.getError()); assertEquals("The resource requested is invalid.", response.getErrorDescription()); @@ -135,7 +131,7 @@ void managedIdentityErrorResponse_fromJson_nestedErrorObject() throws IOExceptio + "\"correlationId\":\"corr-nested\"" + "}"; - ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); assertEquals("ManagedIdentityCredential", response.getError()); assertEquals("Managed identity unavailable", response.getMessage()); @@ -146,7 +142,7 @@ void managedIdentityErrorResponse_fromJson_nestedErrorObject() throws IOExceptio void managedIdentityErrorResponse_fromJson_errorAsString() throws IOException { String json = "{\"error\":\"unauthorized_client\"}"; - ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); assertEquals("unauthorized_client", response.getError()); } @@ -155,7 +151,7 @@ void managedIdentityErrorResponse_fromJson_errorAsString() throws IOException { void managedIdentityErrorResponse_fromJson_unknownFieldsSkipped() throws IOException { String json = "{\"error\":\"test\",\"unknown_field\":42,\"nested_unknown\":{\"a\":1}}"; - ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); assertEquals("test", response.getError()); } @@ -164,7 +160,7 @@ void managedIdentityErrorResponse_fromJson_unknownFieldsSkipped() throws IOExcep void managedIdentityErrorResponse_fromJson_emptyObject() throws IOException { String json = "{}"; - ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); assertNull(response.getError()); assertNull(response.getErrorDescription()); @@ -181,8 +177,8 @@ void managedIdentityErrorResponse_toJson_allFields() throws IOException { + "\"error_description\":\"desc\"" + "}"; - ManagedIdentityErrorResponse response = parseJson(json, ManagedIdentityErrorResponse::fromJson); - String output = writeToJson(response); + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); + String output = TestHelper.writeToJson(response); assertTrue(output.contains("\"message\":\"Identity not found\"")); assertTrue(output.contains("\"correlationId\":\"c-1\"")); @@ -199,35 +195,13 @@ void managedIdentityErrorResponse_toJson_roundTrip() throws IOException { + "\"error_description\":\"desc\"" + "}"; - ManagedIdentityErrorResponse original = parseJson(json, ManagedIdentityErrorResponse::fromJson); - String serialized = writeToJson(original); - ManagedIdentityErrorResponse roundTripped = parseJson(serialized, ManagedIdentityErrorResponse::fromJson); + ManagedIdentityErrorResponse original = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); + String serialized = TestHelper.writeToJson(original); + ManagedIdentityErrorResponse roundTripped = TestHelper.parseJson(serialized, ManagedIdentityErrorResponse::fromJson); assertEquals(original.getError(), roundTripped.getError()); assertEquals(original.getErrorDescription(), roundTripped.getErrorDescription()); assertEquals(original.getMessage(), roundTripped.getMessage()); assertEquals(original.getCorrelationId(), roundTripped.getCorrelationId()); } - - // ========== Helpers ========== - - @FunctionalInterface - interface JsonParser { - T parse(JsonReader reader) throws IOException; - } - - private T parseJson(String json, JsonParser parser) throws IOException { - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - return parser.parse(reader); - } - } - - private > String writeToJson(T serializable) - throws IOException { - StringWriter sw = new StringWriter(); - try (com.azure.json.JsonWriter writer = JsonProviders.createWriter(sw)) { - serializable.toJson(writer); - } - return sw.toString(); - } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java index 0ee998fc0..95a1420f1 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java @@ -3,12 +3,9 @@ package com.microsoft.aad.msal4j; -import com.azure.json.JsonProviders; -import com.azure.json.JsonReader; import org.junit.jupiter.api.Test; import java.io.IOException; -import java.io.StringReader; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -30,10 +27,7 @@ void errorResponse_fromJson_allFieldsPopulated() throws IOException { + "\"correlation_id\":\"corr-456\"," + "\"claims\":\"{\\\"access_token\\\":{\\\"nbf\\\":{\\\"essential\\\":true}}}\"}"; - ErrorResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = ErrorResponse.fromJson(reader); - } + ErrorResponse response = TestHelper.parseJson(json, ErrorResponse::fromJson); assertEquals("invalid_grant", response.error()); assertEquals("Token expired", response.errorDescription()); @@ -49,10 +43,7 @@ void errorResponse_fromJson_allFieldsPopulated() throws IOException { void errorResponse_fromJson_minimalFields() throws IOException { String json = "{\"error\":\"server_error\"}"; - ErrorResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = ErrorResponse.fromJson(reader); - } + ErrorResponse response = TestHelper.parseJson(json, ErrorResponse::fromJson); assertEquals("server_error", response.error()); assertNull(response.errorDescription()); @@ -68,10 +59,7 @@ void errorResponse_fromJson_minimalFields() throws IOException { void errorResponse_fromJson_unknownFieldsSkipped() throws IOException { String json = "{\"error\":\"invalid_request\",\"unknown_field\":\"value\",\"another\":123}"; - ErrorResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = ErrorResponse.fromJson(reader); - } + ErrorResponse response = TestHelper.parseJson(json, ErrorResponse::fromJson); assertEquals("invalid_request", response.error()); } @@ -80,10 +68,7 @@ void errorResponse_fromJson_unknownFieldsSkipped() throws IOException { void errorResponse_fromJson_emptyErrorCodes() throws IOException { String json = "{\"error\":\"test\",\"error_codes\":[]}"; - ErrorResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = ErrorResponse.fromJson(reader); - } + ErrorResponse response = TestHelper.parseJson(json, ErrorResponse::fromJson); assertNotNull(response.errorCodes()); assertEquals(0, response.errorCodes().length); @@ -124,7 +109,7 @@ void errorResponse_toJson_throwsDueToDoubleStartObject() { response.statusCode(400); response.error("invalid_grant"); - assertThrows(IllegalStateException.class, () -> writeToJson(response), + assertThrows(IllegalStateException.class, () -> TestHelper.writeToJson(response), "toJson has a double writeStartObject bug that causes IllegalStateException"); } @@ -135,7 +120,7 @@ void errorResponse_toJson_nullErrorCodes_throwsDueToDoubleStartObject() { response.statusCode(500); response.error("server_error"); - assertThrows(IllegalStateException.class, () -> writeToJson(response)); + assertThrows(IllegalStateException.class, () -> TestHelper.writeToJson(response)); } // ========== UserDiscoveryResponse ========== @@ -149,10 +134,7 @@ void userDiscoveryResponse_fromJson_federatedAccount() throws IOException { + "\"federation_active_auth_url\":\"https://adfs.example.com/active\"," + "\"cloud_audience_urn\":\"urn:federation:MicrosoftOnline\"}"; - UserDiscoveryResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = UserDiscoveryResponse.fromJson(reader); - } + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); assertEquals(1.0f, response.version(), 0.01f); assertEquals("Federated", response.accountType()); @@ -168,10 +150,7 @@ void userDiscoveryResponse_fromJson_federatedAccount() throws IOException { void userDiscoveryResponse_fromJson_managedAccount() throws IOException { String json = "{\"ver\":\"1.0\",\"account_type\":\"Managed\"}"; - UserDiscoveryResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = UserDiscoveryResponse.fromJson(reader); - } + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); assertTrue(response.isAccountManaged()); assertFalse(response.isAccountFederated()); @@ -181,10 +160,7 @@ void userDiscoveryResponse_fromJson_managedAccount() throws IOException { void userDiscoveryResponse_fromJson_unknownAccountType() throws IOException { String json = "{\"ver\":\"2.0\",\"account_type\":\"Unknown\"}"; - UserDiscoveryResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = UserDiscoveryResponse.fromJson(reader); - } + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); assertFalse(response.isAccountFederated()); assertFalse(response.isAccountManaged()); @@ -203,10 +179,7 @@ void userDiscoveryResponse_isAccountFederated_nullAccountType() { void userDiscoveryResponse_isAccountFederated_caseInsensitive() throws IOException { String json = "{\"ver\":\"1.0\",\"account_type\":\"fEdErAtEd\"}"; - UserDiscoveryResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = UserDiscoveryResponse.fromJson(reader); - } + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); assertTrue(response.isAccountFederated()); } @@ -215,10 +188,7 @@ void userDiscoveryResponse_isAccountFederated_caseInsensitive() throws IOExcepti void userDiscoveryResponse_isAccountManaged_caseInsensitive() throws IOException { String json = "{\"ver\":\"1.0\",\"account_type\":\"MANAGED\"}"; - UserDiscoveryResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = UserDiscoveryResponse.fromJson(reader); - } + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); assertTrue(response.isAccountManaged()); } @@ -232,17 +202,11 @@ void userDiscoveryResponse_toJson_roundTrip() throws IOException { + "\"federation_active_auth_url\":\"https://adfs.example.com/active\"," + "\"cloud_audience_urn\":\"urn:federation:MicrosoftOnline\"}"; - UserDiscoveryResponse original; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - original = UserDiscoveryResponse.fromJson(reader); - } + UserDiscoveryResponse original = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); - String serialized = writeToJson(original); + String serialized = TestHelper.writeToJson(original); - UserDiscoveryResponse roundTripped; - try (JsonReader reader = JsonProviders.createReader(new StringReader(serialized))) { - roundTripped = UserDiscoveryResponse.fromJson(reader); - } + UserDiscoveryResponse roundTripped = TestHelper.parseJson(serialized, UserDiscoveryResponse::fromJson); assertEquals(original.accountType(), roundTripped.accountType()); assertEquals(original.federationProtocol(), roundTripped.federationProtocol()); @@ -255,10 +219,7 @@ void userDiscoveryResponse_toJson_roundTrip() throws IOException { void userDiscoveryResponse_fromJson_unknownFieldsSkipped() throws IOException { String json = "{\"ver\":\"1.0\",\"account_type\":\"Managed\",\"extra_field\":\"ignored\"}"; - UserDiscoveryResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = UserDiscoveryResponse.fromJson(reader); - } + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); assertTrue(response.isAccountManaged()); } @@ -272,10 +233,7 @@ void oidcDiscoveryResponse_fromJson_allEndpoints() throws IOException { + "\"device_authorization_endpoint\":\"https://login.microsoftonline.com/common/oauth2/v2.0/devicecode\"," + "\"issuer\":\"https://login.microsoftonline.com/{tenantid}/v2.0\"}"; - OidcDiscoveryResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = OidcDiscoveryResponse.fromJson(reader); - } + OidcDiscoveryResponse response = TestHelper.parseJson(json, OidcDiscoveryResponse::fromJson); assertEquals("https://login.microsoftonline.com/common/oauth2/v2.0/authorize", response.authorizationEndpoint()); assertEquals("https://login.microsoftonline.com/common/oauth2/v2.0/token", response.tokenEndpoint()); @@ -288,10 +246,7 @@ void oidcDiscoveryResponse_fromJson_partialFields() throws IOException { String json = "{\"authorization_endpoint\":\"https://example.com/auth\"," + "\"token_endpoint\":\"https://example.com/token\"}"; - OidcDiscoveryResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = OidcDiscoveryResponse.fromJson(reader); - } + OidcDiscoveryResponse response = TestHelper.parseJson(json, OidcDiscoveryResponse::fromJson); assertEquals("https://example.com/auth", response.authorizationEndpoint()); assertEquals("https://example.com/token", response.tokenEndpoint()); @@ -305,10 +260,7 @@ void oidcDiscoveryResponse_fromJson_unknownFieldsSkipped() throws IOException { + "\"jwks_uri\":\"https://example.com/keys\"," + "\"response_types_supported\":[\"code\"]}"; - OidcDiscoveryResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = OidcDiscoveryResponse.fromJson(reader); - } + OidcDiscoveryResponse response = TestHelper.parseJson(json, OidcDiscoveryResponse::fromJson); assertEquals("https://example.com/auth", response.authorizationEndpoint()); } @@ -321,12 +273,9 @@ void oidcDiscoveryResponse_toJson_doesNotIncludeIssuer() throws IOException { + "\"device_authorization_endpoint\":\"https://example.com/device\"," + "\"issuer\":\"https://example.com/issuer\"}"; - OidcDiscoveryResponse response; - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - response = OidcDiscoveryResponse.fromJson(reader); - } + OidcDiscoveryResponse response = TestHelper.parseJson(json, OidcDiscoveryResponse::fromJson); - String output = writeToJson(response); + String output = TestHelper.writeToJson(response); assertTrue(output.contains("authorization_endpoint")); assertTrue(output.contains("token_endpoint")); @@ -365,10 +314,8 @@ void requestedClaimAdditionalInfo_fromJson_allFields() throws IOException { // Wrap in outer object since fromJson expects to be positioned in a field context String json = "{\"essential\":true,\"value\":\"test-value\",\"values\":[\"v1\",\"v2\"]}"; - RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, null, null); - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - info = info.fromJson(reader); - } + RequestedClaimAdditionalInfo info = TestHelper.parseJson(json, + reader -> new RequestedClaimAdditionalInfo(false, null, null).fromJson(reader)); assertTrue(info.isEssential()); assertEquals("test-value", info.getValue()); @@ -379,10 +326,8 @@ void requestedClaimAdditionalInfo_fromJson_allFields() throws IOException { void requestedClaimAdditionalInfo_fromJson_essentialOnly() throws IOException { String json = "{\"essential\":true}"; - RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, null, null); - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - info = info.fromJson(reader); - } + RequestedClaimAdditionalInfo info = TestHelper.parseJson(json, + reader -> new RequestedClaimAdditionalInfo(false, null, null).fromJson(reader)); assertTrue(info.isEssential()); assertNull(info.getValue()); @@ -393,7 +338,7 @@ void requestedClaimAdditionalInfo_fromJson_essentialOnly() throws IOException { void requestedClaimAdditionalInfo_toJson_essentialTrue() throws IOException { RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, null, null); - String output = writeToJson(info); + String output = TestHelper.writeToJson(info); assertTrue(output.contains("\"essential\":true")); assertFalse(output.contains("\"value\"")); @@ -404,7 +349,7 @@ void requestedClaimAdditionalInfo_toJson_essentialTrue() throws IOException { void requestedClaimAdditionalInfo_toJson_essentialFalseOmitted() throws IOException { RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, "v", null); - String output = writeToJson(info); + String output = TestHelper.writeToJson(info); // essential=false is omitted from serialization assertFalse(output.contains("essential")); @@ -416,7 +361,7 @@ void requestedClaimAdditionalInfo_toJson_withValues() throws IOException { RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo( false, null, Arrays.asList("x", "y")); - String output = writeToJson(info); + String output = TestHelper.writeToJson(info); assertTrue(output.contains("\"values\"")); assertTrue(output.contains("\"x\"")); @@ -428,7 +373,7 @@ void requestedClaimAdditionalInfo_toJson_emptyValuesOmitted() throws IOException RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo( false, null, Arrays.asList()); - String output = writeToJson(info); + String output = TestHelper.writeToJson(info); // Empty list should be omitted (values != null but isEmpty()) assertFalse(output.contains("values")); @@ -492,7 +437,7 @@ void requestedClaim_toJson_withNameAndInfo_throwsDueToBadStringWrite() { RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, null, null); RequestedClaim claim = new RequestedClaim("sub", info); - assertThrows(IllegalStateException.class, () -> writeToJson(claim), + assertThrows(IllegalStateException.class, () -> TestHelper.writeToJson(claim), "toJson writes a raw string in object context, causing IllegalStateException"); } @@ -500,20 +445,10 @@ void requestedClaim_toJson_withNameAndInfo_throwsDueToBadStringWrite() { void requestedClaim_toJson_nullNameAndInfo_writesEmptyObject() throws IOException { RequestedClaim claim = new RequestedClaim(null, null); - String output = writeToJson(claim); + String output = TestHelper.writeToJson(claim); // When name or info is null, toJson skips writing the content assertEquals("{}", output); } - // ========== Helper ========== - - private > String writeToJson(T serializable) - throws IOException { - java.io.StringWriter sw = new java.io.StringWriter(); - try (com.azure.json.JsonWriter writer = JsonProviders.createWriter(sw)) { - serializable.toJson(writer); - } - return sw.toString(); - } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java index f7cc377f9..5852454a3 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java @@ -3,9 +3,16 @@ package com.microsoft.aad.msal4j; +import com.azure.json.JsonProviders; +import com.azure.json.JsonReader; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonWriter; + import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; @@ -351,4 +358,32 @@ static PrivateKey getPrivateKey() { return privateKey; } + + // --- JSON parsing/serialization helpers --- + + @FunctionalInterface + interface JsonParser { + T parse(JsonReader reader) throws IOException; + } + + /** + * Parses a JSON string into an object using the provided parser function. + * Handles JsonReader lifecycle automatically. + */ + static T parseJson(String json, JsonParser parser) throws IOException { + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + return parser.parse(reader); + } + } + + /** + * Serializes a JsonSerializable object to a JSON string. + */ + static > String writeToJson(T serializable) throws IOException { + StringWriter sw = new StringWriter(); + try (JsonWriter writer = JsonProviders.createWriter(sw)) { + serializable.toJson(writer); + } + return sw.toString(); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java index 43214e88e..c8f84ffa0 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java @@ -13,10 +13,19 @@ class WSTrustResponseTest { private static final String AAD_TOKEN_ERROR_FILE = "/token-error.xml"; + private String successXml; + private String errorXml; + private WSTrustResponse successResponse; + @BeforeEach - void setup() { + void setup() throws Exception { System.setProperty("javax.xml.parsers.DocumentBuilderFactory", "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl"); + + successXml = TestHelper.readResource(WSTrustResponseTest.class, + TestConfiguration.AAD_TOKEN_SUCCESS_FILE); + errorXml = TestHelper.readResource(WSTrustResponseTest.class, AAD_TOKEN_ERROR_FILE); + successResponse = WSTrustResponse.parse(successXml, WSTrustVersion.WSTRUST13); } @AfterEach @@ -25,46 +34,29 @@ void cleanup() { } @Test - void parse_ValidToken_ReturnsResponseWithToken() throws Exception { - String xml = TestHelper.readResource(WSTrustResponseTest.class, - TestConfiguration.AAD_TOKEN_SUCCESS_FILE); - - WSTrustResponse response = WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13); - - assertNotNull(response); - assertNotNull(response.getToken(), "Parsed response should contain a token"); - assertNotNull(response.getTokenType(), "Parsed response should contain a token type"); - assertFalse(response.isErrorFound(), "Successful parse should not have error"); + void parse_ValidToken_ReturnsResponseWithToken() { + assertNotNull(successResponse); + assertNotNull(successResponse.getToken(), "Parsed response should contain a token"); + assertNotNull(successResponse.getTokenType(), "Parsed response should contain a token type"); + assertFalse(successResponse.isErrorFound(), "Successful parse should not have error"); } @Test - void parse_ValidToken_TokenTypeIsSaml1() throws Exception { - String xml = TestHelper.readResource(WSTrustResponseTest.class, - TestConfiguration.AAD_TOKEN_SUCCESS_FILE); - - WSTrustResponse response = WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13); - - assertEquals(WSTrustResponse.SAML1_ASSERTION, response.getTokenType()); - assertFalse(response.isTokenSaml2(), "SAML 1.0 token should not be detected as SAML 2"); + void parse_ValidToken_TokenTypeIsSaml1() { + assertEquals(WSTrustResponse.SAML1_ASSERTION, successResponse.getTokenType()); + assertFalse(successResponse.isTokenSaml2(), "SAML 1.0 token should not be detected as SAML 2"); } @Test - void parse_ValidToken_TokenContainsSamlAssertion() throws Exception { - String xml = TestHelper.readResource(WSTrustResponseTest.class, - TestConfiguration.AAD_TOKEN_SUCCESS_FILE); - - WSTrustResponse response = WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13); - - assertTrue(response.getToken().contains("saml:Assertion"), + void parse_ValidToken_TokenContainsSamlAssertion() { + assertTrue(successResponse.getToken().contains("saml:Assertion"), "Token should contain SAML assertion XML"); } @Test void parse_ErrorResponse_ThrowsMsalServiceException() { - String xml = TestHelper.readResource(WSTrustResponseTest.class, AAD_TOKEN_ERROR_FILE); - MsalServiceException ex = assertThrows(MsalServiceException.class, - () -> WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13)); + () -> WSTrustResponse.parse(errorXml, WSTrustVersion.WSTRUST13)); assertEquals(AuthenticationErrorCode.WSTRUST_SERVICE_ERROR, ex.errorCode()); assertTrue(ex.getMessage().contains("ErrorCode:"), @@ -75,12 +67,9 @@ void parse_ErrorResponse_ThrowsMsalServiceException() { @Test void parse_ErrorResponse_ContainsReasonAndSubcode() { - String xml = TestHelper.readResource(WSTrustResponseTest.class, AAD_TOKEN_ERROR_FILE); - MsalServiceException ex = assertThrows(MsalServiceException.class, - () -> WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13)); + () -> WSTrustResponse.parse(errorXml, WSTrustVersion.WSTRUST13)); - // The error XML has subcode "a:RequestFailed" which splits to "RequestFailed" assertTrue(ex.getMessage().contains("RequestFailed"), "Exception should contain parsed error subcode"); assertTrue(ex.getMessage().contains("MSIS3127"), @@ -89,40 +78,27 @@ void parse_ErrorResponse_ContainsReasonAndSubcode() { @Test void parse_UndefinedVersion_ThrowsException() { - String xml = TestHelper.readResource(WSTrustResponseTest.class, - TestConfiguration.AAD_TOKEN_SUCCESS_FILE); - // UNDEFINED version has empty XPaths which cause XPathExpressionException assertThrows(Exception.class, - () -> WSTrustResponse.parse(xml, WSTrustVersion.UNDEFINED)); + () -> WSTrustResponse.parse(successXml, WSTrustVersion.UNDEFINED)); } @Test - void isTokenSaml2_nonSaml1Type_returnsTrue() throws Exception { - String xml = TestHelper.readResource(WSTrustResponseTest.class, - TestConfiguration.AAD_TOKEN_SUCCESS_FILE); - WSTrustResponse response = WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13); - + void isTokenSaml2_nonSaml1Type_returnsTrue() { // Token from test XML is SAML 1.0 - assertFalse(response.isTokenSaml2()); + assertFalse(successResponse.isTokenSaml2()); - // Any non-SAML1 type should return true for isTokenSaml2 - // We can verify the constant value + // Verify the constant value assertEquals("urn:oasis:names:tc:SAML:1.0:assertion", WSTrustResponse.SAML1_ASSERTION); } @Test - void parse_ValidToken_GettersReturnExpectedValues() throws Exception { - String xml = TestHelper.readResource(WSTrustResponseTest.class, - TestConfiguration.AAD_TOKEN_SUCCESS_FILE); - - WSTrustResponse response = WSTrustResponse.parse(xml, WSTrustVersion.WSTRUST13); - - assertFalse(response.isErrorFound()); - assertNull(response.getFaultMessage()); - assertNull(response.getErrorCode()); - assertNotNull(response.getToken()); - assertFalse(response.getToken().isEmpty()); + void parse_ValidToken_GettersReturnExpectedValues() { + assertFalse(successResponse.isErrorFound()); + assertNull(successResponse.getFaultMessage()); + assertNull(successResponse.getErrorCode()); + assertNotNull(successResponse.getToken()); + assertFalse(successResponse.getToken().isEmpty()); } @Test diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java index 3213234f5..98388e510 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java @@ -3,13 +3,9 @@ package com.microsoft.aad.msal4j; -import com.azure.json.JsonProviders; -import com.azure.json.JsonReader; import org.junit.jupiter.api.Test; import java.io.IOException; -import java.io.StringReader; -import java.io.StringWriter; import java.util.Collections; import static org.junit.jupiter.api.Assertions.*; @@ -28,7 +24,7 @@ void credential_fromJson_allFields() throws IOException { + "\"user_assertion_hash\":\"hash-abc\"" + "}"; - Credential cred = parseJson(json, Credential::fromJson); + Credential cred = TestHelper.parseJson(json, Credential::fromJson); assertEquals("uid.utid", cred.homeAccountId()); assertEquals("login.microsoftonline.com", cred.environment()); @@ -41,7 +37,7 @@ void credential_fromJson_allFields() throws IOException { void credential_fromJson_partialFields() throws IOException { String json = "{\"home_account_id\":\"uid\",\"environment\":\"env\"}"; - Credential cred = parseJson(json, Credential::fromJson); + Credential cred = TestHelper.parseJson(json, Credential::fromJson); assertEquals("uid", cred.homeAccountId()); assertEquals("env", cred.environment()); @@ -54,7 +50,7 @@ void credential_fromJson_partialFields() throws IOException { void credential_fromJson_unknownFieldsSkipped() throws IOException { String json = "{\"home_account_id\":\"uid\",\"extra\":123}"; - Credential cred = parseJson(json, Credential::fromJson); + Credential cred = TestHelper.parseJson(json, Credential::fromJson); assertEquals("uid", cred.homeAccountId()); } @@ -85,7 +81,7 @@ void credential_toJson_allFields() throws IOException { cred.secret("sec"); cred.userAssertionHash("hash"); - String output = writeToJson(cred); + String output = TestHelper.writeToJson(cred); assertTrue(output.contains("\"home_account_id\":\"uid.utid\"")); assertTrue(output.contains("\"environment\":\"login.microsoftonline.com\"")); @@ -104,9 +100,9 @@ void credential_toJson_roundTrip() throws IOException { + "\"user_assertion_hash\":\"hash\"" + "}"; - Credential original = parseJson(json, Credential::fromJson); - String serialized = writeToJson(original); - Credential roundTripped = parseJson(serialized, Credential::fromJson); + Credential original = TestHelper.parseJson(json, Credential::fromJson); + String serialized = TestHelper.writeToJson(original); + Credential roundTripped = TestHelper.parseJson(serialized, Credential::fromJson); assertEquals(original.homeAccountId(), roundTripped.homeAccountId()); assertEquals(original.environment(), roundTripped.environment()); @@ -194,7 +190,7 @@ void idToken_fromJson_allFields() throws IOException { + "\"unique_name\":\"testuser\"" + "}"; - IdToken idToken = parseJson(json, IdToken::fromJson); + IdToken idToken = TestHelper.parseJson(json, IdToken::fromJson); assertEquals("https://login.microsoftonline.com/tenant/v2.0", idToken.issuer); assertEquals("sub-123", idToken.subject); @@ -214,7 +210,7 @@ void idToken_fromJson_allFields() throws IOException { void idToken_fromJson_partialFields() throws IOException { String json = "{\"iss\":\"issuer\",\"sub\":\"subject\",\"aud\":\"audience\"}"; - IdToken idToken = parseJson(json, IdToken::fromJson); + IdToken idToken = TestHelper.parseJson(json, IdToken::fromJson); assertEquals("issuer", idToken.issuer); assertEquals("subject", idToken.subject); @@ -234,7 +230,7 @@ void idToken_fromJson_partialFields() throws IOException { void idToken_fromJson_unknownFieldsSkipped() throws IOException { String json = "{\"iss\":\"issuer\",\"nonce\":\"abc\",\"email\":\"user@test.com\"}"; - IdToken idToken = parseJson(json, IdToken::fromJson); + IdToken idToken = TestHelper.parseJson(json, IdToken::fromJson); assertEquals("issuer", idToken.issuer); } @@ -255,7 +251,7 @@ void idToken_toJson_allFields() throws IOException { idToken.upn = "user@example.com"; idToken.uniqueName = "unique"; - String output = writeToJson(idToken); + String output = TestHelper.writeToJson(idToken); assertTrue(output.contains("\"iss\":\"iss\"")); assertTrue(output.contains("\"sub\":\"sub\"")); @@ -285,9 +281,9 @@ void idToken_toJson_roundTrip() throws IOException { + "\"unique_name\":\"unique-1\"" + "}"; - IdToken original = parseJson(json, IdToken::fromJson); - String serialized = writeToJson(original); - IdToken roundTripped = parseJson(serialized, IdToken::fromJson); + IdToken original = TestHelper.parseJson(json, IdToken::fromJson); + String serialized = TestHelper.writeToJson(original); + IdToken roundTripped = TestHelper.parseJson(serialized, IdToken::fromJson); assertEquals(original.issuer, roundTripped.issuer); assertEquals(original.subject, roundTripped.subject); @@ -322,26 +318,4 @@ void wsTrustVersion_wsTrust13_containsCorrectPaths() { assertTrue(WSTrustVersion.WSTRUST13.responseSecurityTokenPath() .contains("RequestedSecurityToken")); } - - // ========== Helpers ========== - - @FunctionalInterface - interface JsonParser { - T parse(JsonReader reader) throws IOException; - } - - private T parseJson(String json, JsonParser parser) throws IOException { - try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { - return parser.parse(reader); - } - } - - private > String writeToJson(T serializable) - throws IOException { - StringWriter sw = new StringWriter(); - try (com.azure.json.JsonWriter writer = JsonProviders.createWriter(sw)) { - serializable.toJson(writer); - } - return sw.toString(); - } } From 95c9d981a67e1d212198b741af37eaf4db293b6f Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 8 Jun 2026 16:19:00 -0700 Subject: [PATCH 23/24] Misc. tests --- .../aad/msal4j/AcquireTokenSilentlyTest.java | 92 ++++++++ .../aad/msal4j/ParameterBuilderTest.java | 19 ++ .../aad/msal4j/ResponseParsingTest.java | 212 ++++++++++++++++++ 3 files changed, 323 insertions(+) diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java index 036a4f040..bf6163bcd 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java @@ -197,4 +197,96 @@ private void assertRefreshedToken(IAuthenticationResult result, String expectedT assertEquals(expectedToken, result.accessToken()); assertEquals(expectedReason, result.metadata().cacheRefreshReason()); } + + // ========== SilentRequestHelper ========== + + @Test + void getCacheRefreshReason_claimsPresent_returnsClaims() { + SilentParameters params = SilentParameters.builder( + Collections.singleton("scope"), + mock(IAccount.class)) + .claims(new ClaimsRequest()) + .build(); + + AuthenticationResult cachedResult = mock(AuthenticationResult.class); + when(cachedResult.accessToken()).thenReturn("valid-token"); + when(cachedResult.expiresOn()).thenReturn(System.currentTimeMillis() / 1000 + 3600); + + org.slf4j.Logger log = mock(org.slf4j.Logger.class); + + assertEquals(CacheRefreshReason.CLAIMS, + SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); + } + + @Test + void getCacheRefreshReason_expiredToken_returnsExpired() { + SilentParameters params = SilentParameters.builder( + Collections.singleton("scope"), + mock(IAccount.class)) + .build(); + + AuthenticationResult cachedResult = mock(AuthenticationResult.class); + when(cachedResult.accessToken()).thenReturn("expired-token"); + when(cachedResult.expiresOn()).thenReturn(System.currentTimeMillis() / 1000 - 600); + + org.slf4j.Logger log = mock(org.slf4j.Logger.class); + + assertEquals(CacheRefreshReason.EXPIRED, + SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); + } + + @Test + void getCacheRefreshReason_proactiveRefresh_returnsProactiveRefresh() { + SilentParameters params = SilentParameters.builder( + Collections.singleton("scope"), + mock(IAccount.class)) + .build(); + + long now = System.currentTimeMillis() / 1000; + AuthenticationResult cachedResult = mock(AuthenticationResult.class); + when(cachedResult.accessToken()).thenReturn("valid-token"); + when(cachedResult.expiresOn()).thenReturn(now + 3600); + when(cachedResult.refreshOn()).thenReturn(now - 600); + + org.slf4j.Logger log = mock(org.slf4j.Logger.class); + + assertEquals(CacheRefreshReason.PROACTIVE_REFRESH, + SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); + } + + @Test + void getCacheRefreshReason_noAccessTokenWithRefreshToken_returnsNoCachedAccessToken() { + SilentParameters params = SilentParameters.builder( + Collections.singleton("scope"), + mock(IAccount.class)) + .build(); + + AuthenticationResult cachedResult = mock(AuthenticationResult.class); + when(cachedResult.accessToken()).thenReturn(null); + when(cachedResult.refreshToken()).thenReturn("refresh-token-value"); + + org.slf4j.Logger log = mock(org.slf4j.Logger.class); + + assertEquals(CacheRefreshReason.NO_CACHED_ACCESS_TOKEN, + SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); + } + + @Test + void getCacheRefreshReason_validToken_returnsNotApplicable() { + SilentParameters params = SilentParameters.builder( + Collections.singleton("scope"), + mock(IAccount.class)) + .build(); + + long now = System.currentTimeMillis() / 1000; + AuthenticationResult cachedResult = mock(AuthenticationResult.class); + when(cachedResult.accessToken()).thenReturn("valid-token"); + when(cachedResult.expiresOn()).thenReturn(now + 3600); + when(cachedResult.refreshOn()).thenReturn(null); + + org.slf4j.Logger log = mock(org.slf4j.Logger.class); + + assertEquals(CacheRefreshReason.NOT_APPLICABLE, + SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java index 6d5014b7e..b5ac5533f 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java @@ -860,4 +860,23 @@ void managedIdentity_BlankClaims_Throws() { assertThrows(IllegalArgumentException.class, () -> ManagedIdentityParameters.builder("https://management.azure.com").claims(" ")); } + + // ========== ParameterValidationUtils ========== + + @Test + void validateNotEmpty_set_nullThrows() { + assertThrows(IllegalArgumentException.class, + () -> ParameterValidationUtils.validateNotEmpty("scopes", (Set) null)); + } + + @Test + void validateNotEmpty_set_emptyThrows() { + assertThrows(IllegalArgumentException.class, + () -> ParameterValidationUtils.validateNotEmpty("scopes", new HashSet<>())); + } + + @Test + void validateNotEmpty_set_nonEmptyPasses() { + ParameterValidationUtils.validateNotEmpty("scopes", Collections.singleton("openid")); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java index 95a1420f1..6768dffd2 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java @@ -7,10 +7,14 @@ import java.io.IOException; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class ResponseParsingTest { @@ -451,4 +455,212 @@ void requestedClaim_toJson_nullNameAndInfo_writesEmptyObject() throws IOExceptio assertEquals("{}", output); } + // ========== ClientInfo ========== + + @Test + void clientInfo_createFromJson_validBase64() { + String json = "{\"uid\":\"user-id\",\"utid\":\"tenant-id\"}"; + String base64 = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(json.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + ClientInfo info = ClientInfo.createFromJson(base64); + + assertNotNull(info); + assertEquals("user-id", info.getUniqueIdentifier()); + assertEquals("tenant-id", info.getUniqueTenantIdentifier()); + assertEquals("user-id.tenant-id", info.toAccountIdentifier()); + } + + @Test + void clientInfo_createFromJson_blankInput_returnsNull() { + assertNull(ClientInfo.createFromJson(null)); + assertNull(ClientInfo.createFromJson("")); + assertNull(ClientInfo.createFromJson(" ")); + } + + @Test + void clientInfo_toJson_roundTrip() throws IOException { + String json = "{\"uid\":\"uid-1\",\"utid\":\"utid-1\"}"; + ClientInfo original = TestHelper.parseJson(json, ClientInfo::fromJson); + + String serialized = TestHelper.writeToJson(original); + + assertTrue(serialized.contains("\"uid\":\"uid-1\"")); + assertTrue(serialized.contains("\"utid\":\"utid-1\"")); + + ClientInfo roundTripped = TestHelper.parseJson(serialized, ClientInfo::fromJson); + assertEquals(original.getUniqueIdentifier(), roundTripped.getUniqueIdentifier()); + assertEquals(original.getUniqueTenantIdentifier(), roundTripped.getUniqueTenantIdentifier()); + } + + @Test + void clientInfo_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"uid\":\"u\",\"utid\":\"t\",\"extra\":\"ignored\"}"; + + ClientInfo info = TestHelper.parseJson(json, ClientInfo::fromJson); + + assertEquals("u", info.getUniqueIdentifier()); + assertEquals("t", info.getUniqueTenantIdentifier()); + } + + // ========== MsalServiceException ========== + + @Test + void msalServiceException_errorResponseConstructor() { + ErrorResponse errorResponse = new ErrorResponse(); + errorResponse.statusCode(401); + errorResponse.statusMessage("Unauthorized"); + errorResponse.error("invalid_token"); + errorResponse.errorDescription("Token is expired"); + errorResponse.subError("token_expired"); + errorResponse.correlation_id("corr-123"); + errorResponse.claims("{\"access_token\":{}}"); + + Map> headers = new HashMap<>(); + headers.put("WWW-Authenticate", Collections.singletonList("Bearer")); + + MsalServiceException ex = new MsalServiceException(errorResponse, headers); + + assertEquals(401, ex.statusCode().intValue()); + assertEquals("Unauthorized", ex.statusMessage()); + assertEquals("invalid_token", ex.errorCode()); + assertEquals("Token is expired", ex.getMessage()); + assertEquals("token_expired", ex.subError()); + assertEquals("corr-123", ex.correlationId()); + assertEquals("{\"access_token\":{}}", ex.claims()); + assertNotNull(ex.headers()); + assertTrue(ex.headers().containsKey("WWW-Authenticate")); + } + + @Test + void msalServiceException_discoveryResponseConstructor() throws IOException { + String json = "{\"error\":\"invalid_instance\"," + + "\"error_description\":\"AADSTS50049: Unknown instance.\"," + + "\"correlation_id\":\"disc-corr\"}"; + + AadInstanceDiscoveryResponse discoveryResponse = + TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + MsalServiceException ex = new MsalServiceException(discoveryResponse); + + assertEquals("invalid_instance", ex.errorCode()); + assertEquals("AADSTS50049: Unknown instance.", ex.getMessage()); + assertEquals("disc-corr", ex.correlationId()); + assertNull(ex.statusCode()); + assertNull(ex.headers()); + } + + @Test + void msalServiceException_managedIdentityConstructor() { + MsalServiceException ex = new MsalServiceException( + "MI error", "managed_identity_error", + ManagedIdentitySourceType.APP_SERVICE); + + assertEquals("MI error", ex.getMessage()); + assertEquals("managed_identity_error", ex.errorCode()); + assertEquals("APP_SERVICE", ex.managedIdentitySource()); + } + + // ========== MsalServiceExceptionFactory ========== + + @Test + void msalServiceExceptionFactory_blankBody_returnsUnknownError() { + IHttpResponse response = mock(IHttpResponse.class); + when(response.body()).thenReturn(""); + when(response.statusCode()).thenReturn(500); + + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + + assertEquals(AuthenticationErrorCode.UNKNOWN, ex.errorCode()); + assertTrue(ex.getMessage().contains("500")); + assertTrue(ex.getMessage().contains("no response body")); + } + + @Test + void msalServiceExceptionFactory_nullBody_returnsUnknownError() { + IHttpResponse response = mock(IHttpResponse.class); + when(response.body()).thenReturn(null); + when(response.statusCode()).thenReturn(503); + + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + + assertEquals(AuthenticationErrorCode.UNKNOWN, ex.errorCode()); + assertTrue(ex.getMessage().contains("503")); + } + + @Test + void msalServiceExceptionFactory_invalidGrant_interactionRequired() { + String body = "{\"error\":\"invalid_grant\"," + + "\"error_description\":\"AADSTS50076: needs MFA\"," + + "\"suberror\":\"basic_action\"}"; + + Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json")); + + IHttpResponse response = mock(IHttpResponse.class); + when(response.body()).thenReturn(body); + when(response.statusCode()).thenReturn(400); + when(response.headers()).thenReturn(headers); + + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + + assertTrue(ex instanceof MsalInteractionRequiredException, + "invalid_grant with UI-required suberror should return MsalInteractionRequiredException"); + } + + @Test + void msalServiceExceptionFactory_invalidGrant_clientMismatch_notInteractionRequired() { + String body = "{\"error\":\"invalid_grant\"," + + "\"error_description\":\"Client mismatch\"," + + "\"suberror\":\"client_mismatch\"}"; + + Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json")); + + IHttpResponse response = mock(IHttpResponse.class); + when(response.body()).thenReturn(body); + when(response.statusCode()).thenReturn(400); + when(response.headers()).thenReturn(headers); + + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + + assertFalse(ex instanceof MsalInteractionRequiredException, + "client_mismatch suberror should NOT return MsalInteractionRequiredException"); + assertEquals(400, ex.statusCode().intValue()); + } + + @Test + void msalServiceExceptionFactory_invalidGrant_protectionPolicyRequired_notInteractionRequired() { + String body = "{\"error\":\"invalid_grant\"," + + "\"error_description\":\"Protection policy required\"," + + "\"suberror\":\"protection_policy_required\"}"; + + Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json")); + + IHttpResponse response = mock(IHttpResponse.class); + when(response.body()).thenReturn(body); + when(response.statusCode()).thenReturn(400); + when(response.headers()).thenReturn(headers); + + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + + assertFalse(ex instanceof MsalInteractionRequiredException, + "protection_policy_required suberror should NOT return MsalInteractionRequiredException"); + } + + @Test + void msalServiceExceptionFactory_noErrorInBody_returnsUnknownError() { + String body = "{\"some_field\":\"some_value\"}"; + + IHttpResponse response = mock(IHttpResponse.class); + when(response.body()).thenReturn(body); + when(response.statusCode()).thenReturn(500); + + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + + assertEquals(AuthenticationErrorCode.UNKNOWN, ex.errorCode()); + assertTrue(ex.getMessage().contains("500")); + } + } From bebe2d7a9f6b73043b6401503e4531aae9145843 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 9 Jun 2026 07:19:36 -0700 Subject: [PATCH 24/24] Minor improvements --- .../aad/msal4j/AcquireTokenSilentlyTest.java | 18 ++-- .../aad/msal4j/ResponseParsingTest.java | 83 +++++++++---------- 2 files changed, 45 insertions(+), 56 deletions(-) diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java index bf6163bcd..332a79fc6 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java @@ -5,11 +5,7 @@ import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import java.util.Collections; @@ -18,6 +14,8 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; + class AcquireTokenSilentlyTest { @@ -212,7 +210,7 @@ void getCacheRefreshReason_claimsPresent_returnsClaims() { when(cachedResult.accessToken()).thenReturn("valid-token"); when(cachedResult.expiresOn()).thenReturn(System.currentTimeMillis() / 1000 + 3600); - org.slf4j.Logger log = mock(org.slf4j.Logger.class); + Logger log = mock(Logger.class); assertEquals(CacheRefreshReason.CLAIMS, SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); @@ -229,7 +227,7 @@ void getCacheRefreshReason_expiredToken_returnsExpired() { when(cachedResult.accessToken()).thenReturn("expired-token"); when(cachedResult.expiresOn()).thenReturn(System.currentTimeMillis() / 1000 - 600); - org.slf4j.Logger log = mock(org.slf4j.Logger.class); + Logger log = mock(Logger.class); assertEquals(CacheRefreshReason.EXPIRED, SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); @@ -248,7 +246,7 @@ void getCacheRefreshReason_proactiveRefresh_returnsProactiveRefresh() { when(cachedResult.expiresOn()).thenReturn(now + 3600); when(cachedResult.refreshOn()).thenReturn(now - 600); - org.slf4j.Logger log = mock(org.slf4j.Logger.class); + Logger log = mock(Logger.class); assertEquals(CacheRefreshReason.PROACTIVE_REFRESH, SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); @@ -265,7 +263,7 @@ void getCacheRefreshReason_noAccessTokenWithRefreshToken_returnsNoCachedAccessTo when(cachedResult.accessToken()).thenReturn(null); when(cachedResult.refreshToken()).thenReturn("refresh-token-value"); - org.slf4j.Logger log = mock(org.slf4j.Logger.class); + Logger log = mock(Logger.class); assertEquals(CacheRefreshReason.NO_CACHED_ACCESS_TOKEN, SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); @@ -284,7 +282,7 @@ void getCacheRefreshReason_validToken_returnsNotApplicable() { when(cachedResult.expiresOn()).thenReturn(now + 3600); when(cachedResult.refreshOn()).thenReturn(null); - org.slf4j.Logger log = mock(org.slf4j.Logger.class); + Logger log = mock(Logger.class); assertEquals(CacheRefreshReason.NOT_APPLICABLE, SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java index 6768dffd2..52ce2730b 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java @@ -6,7 +6,9 @@ import org.junit.jupiter.api.Test; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -460,8 +462,8 @@ void requestedClaim_toJson_nullNameAndInfo_writesEmptyObject() throws IOExceptio @Test void clientInfo_createFromJson_validBase64() { String json = "{\"uid\":\"user-id\",\"utid\":\"tenant-id\"}"; - String base64 = java.util.Base64.getUrlEncoder().withoutPadding() - .encodeToString(json.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String base64 = Base64.getUrlEncoder().withoutPadding() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); ClientInfo info = ClientInfo.createFromJson(base64); @@ -563,13 +565,31 @@ void msalServiceException_managedIdentityConstructor() { // ========== MsalServiceExceptionFactory ========== - @Test - void msalServiceExceptionFactory_blankBody_returnsUnknownError() { + private IHttpResponse mockHttpResponse(String body, int statusCode) { + return mockHttpResponse(body, statusCode, null); + } + + private IHttpResponse mockHttpResponse(String body, int statusCode, + Map> headers) { IHttpResponse response = mock(IHttpResponse.class); - when(response.body()).thenReturn(""); - when(response.statusCode()).thenReturn(500); + when(response.body()).thenReturn(body); + when(response.statusCode()).thenReturn(statusCode); + if (headers != null) { + when(response.headers()).thenReturn(headers); + } + return response; + } + + private Map> jsonContentTypeHeaders() { + Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json")); + return headers; + } - MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + @Test + void msalServiceExceptionFactory_blankBody_returnsUnknownError() { + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse("", 500)); assertEquals(AuthenticationErrorCode.UNKNOWN, ex.errorCode()); assertTrue(ex.getMessage().contains("500")); @@ -578,11 +598,8 @@ void msalServiceExceptionFactory_blankBody_returnsUnknownError() { @Test void msalServiceExceptionFactory_nullBody_returnsUnknownError() { - IHttpResponse response = mock(IHttpResponse.class); - when(response.body()).thenReturn(null); - when(response.statusCode()).thenReturn(503); - - MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse(null, 503)); assertEquals(AuthenticationErrorCode.UNKNOWN, ex.errorCode()); assertTrue(ex.getMessage().contains("503")); @@ -594,15 +611,8 @@ void msalServiceExceptionFactory_invalidGrant_interactionRequired() { + "\"error_description\":\"AADSTS50076: needs MFA\"," + "\"suberror\":\"basic_action\"}"; - Map> headers = new HashMap<>(); - headers.put("Content-Type", Collections.singletonList("application/json")); - - IHttpResponse response = mock(IHttpResponse.class); - when(response.body()).thenReturn(body); - when(response.statusCode()).thenReturn(400); - when(response.headers()).thenReturn(headers); - - MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse(body, 400, jsonContentTypeHeaders())); assertTrue(ex instanceof MsalInteractionRequiredException, "invalid_grant with UI-required suberror should return MsalInteractionRequiredException"); @@ -614,15 +624,8 @@ void msalServiceExceptionFactory_invalidGrant_clientMismatch_notInteractionRequi + "\"error_description\":\"Client mismatch\"," + "\"suberror\":\"client_mismatch\"}"; - Map> headers = new HashMap<>(); - headers.put("Content-Type", Collections.singletonList("application/json")); - - IHttpResponse response = mock(IHttpResponse.class); - when(response.body()).thenReturn(body); - when(response.statusCode()).thenReturn(400); - when(response.headers()).thenReturn(headers); - - MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse(body, 400, jsonContentTypeHeaders())); assertFalse(ex instanceof MsalInteractionRequiredException, "client_mismatch suberror should NOT return MsalInteractionRequiredException"); @@ -635,15 +638,8 @@ void msalServiceExceptionFactory_invalidGrant_protectionPolicyRequired_notIntera + "\"error_description\":\"Protection policy required\"," + "\"suberror\":\"protection_policy_required\"}"; - Map> headers = new HashMap<>(); - headers.put("Content-Type", Collections.singletonList("application/json")); - - IHttpResponse response = mock(IHttpResponse.class); - when(response.body()).thenReturn(body); - when(response.statusCode()).thenReturn(400); - when(response.headers()).thenReturn(headers); - - MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse(body, 400, jsonContentTypeHeaders())); assertFalse(ex instanceof MsalInteractionRequiredException, "protection_policy_required suberror should NOT return MsalInteractionRequiredException"); @@ -651,13 +647,8 @@ void msalServiceExceptionFactory_invalidGrant_protectionPolicyRequired_notIntera @Test void msalServiceExceptionFactory_noErrorInBody_returnsUnknownError() { - String body = "{\"some_field\":\"some_value\"}"; - - IHttpResponse response = mock(IHttpResponse.class); - when(response.body()).thenReturn(body); - when(response.statusCode()).thenReturn(500); - - MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse(response); + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse("{\"some_field\":\"some_value\"}", 500)); assertEquals(AuthenticationErrorCode.UNKNOWN, ex.errorCode()); assertTrue(ex.getMessage().contains("500"));