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 60b5f434..05e5e99e 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/AcquireTokenSilentlyTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java index aae1214d..036a4f04 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); @@ -119,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 new file mode 100644 index 00000000..665ecb02 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java @@ -0,0 +1,245 @@ +// 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.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; +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 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; + + // ======================================================================== + // Helpers + // ======================================================================== + + private static TokenProviderResult validTokenProviderResult() { + TokenProviderResult result = new TokenProviderResult(); + result.setAccessToken(VALID_ACCESS_TOKEN); + result.setExpiresInSeconds(ONE_HOUR_SECONDS); + result.setTenantId(TENANT_ID); + return result; + } + + 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 = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(validTokenProviderResult())); + + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get(); + + assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); + } + + @Test + void appTokenProvider_ReceivesCorrectParameters() throws Exception { + @SuppressWarnings("unchecked") + Function> provider = + mock(Function.class); + + when(provider.apply(any())).thenReturn( + CompletableFuture.completedFuture(validTokenProviderResult())); + + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(provider); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(TestHelper.TEST_SCOPE_SET) + .tenant("override-tenant") + .build(); + + cca.acquireToken(parameters).get(); + + verify(provider).apply(argThat(params -> { + assertTrue(params.getScopes().contains(TestHelper.TEST_SCOPE)); + 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 = TestHelper.buildCcaWithAppTokenProvider(provider); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(TestHelper.TEST_SCOPE_SET).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 = TestHelper.buildCcaWithAppTokenProvider(provider); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(TestHelper.TEST_SCOPE_SET) + .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: invalid TokenProviderResult fields + // ======================================================================== + + @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(TestHelper.TEST_SCOPE_SET).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(""))) + ); + } + + // ======================================================================== + // refreshInSeconds auto-calculation + // ======================================================================== + + @Test + void appTokenProvider_ExpiryAtLeastTwoHours_AutoCalculatesRefreshIn() throws Exception { + TokenProviderResult providerResult = validTokenProviderResult(); + providerResult.setExpiresInSeconds(TWO_HOURS_SECONDS); + providerResult.setRefreshInSeconds(0); + + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(providerResult)); + + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get(); + + assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); + 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 = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(providerResult)); + + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).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 = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(providerResult)); + + 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 + 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 = TestHelper.buildCcaWithAppTokenProvider(throwingProvider); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> 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 new file mode 100644 index 00000000..ff8d71f2 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java @@ -0,0 +1,541 @@ +// 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 = TestHelper.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()); + } + + // ======================================================================== + // 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) + .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/AuthenticationResultTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultTest.java new file mode 100644 index 00000000..22ab1680 --- /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")); + } +} 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 a200a24a..90ef7038 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/CacheFormatTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java index 35d2dbf4..0672e91a 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 11f0d1c0..163d3aee 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,20 +6,16 @@ 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; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; class CacheTest { @@ -34,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 @@ -59,9 +49,9 @@ 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))); - OnBehalfOfParameters onBehalfOfParametersarameters = OnBehalfOfParameters.builder(Collections.singleton("someOtherScopes"), new UserAssertion(TestHelper.signedAssertion)).build(); - IAuthenticationResult resultWithAccount = cca.acquireToken(onBehalfOfParametersarameters).get(); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, responseParameters); + 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 +316,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/ClientCredentialTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java index 5c818757..5129d3a9 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,46 +45,34 @@ 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<>()))); + 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(); - //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()); } @Test - void OnBehalfOf_TenantOverride() 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(); @@ -98,8 +84,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 = 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/FmiTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java index 6ef00adf..58abdf46 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 { // ======================================================================== 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 b48d0eb3..0c0a34ba 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/ParameterBuilderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java new file mode 100644 index 00000000..6d5014b7 --- /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(" ")); + } +} 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 4112dceb..f7cc377f 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(); @@ -74,6 +150,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(); @@ -153,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")) : @@ -194,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,