diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/BootConfig.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/BootConfig.java index 06e795365a..fd813c0695 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/BootConfig.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/BootConfig.java @@ -116,6 +116,18 @@ static BootConfig getHybridBootConfig(Context ctx, String assetFilePath) { return hybridBootConfig; } + /** + * Gets a native boot config instance from XML resources. + * Package-private for testing purposes. + * @param ctx The context used to read XML resources. + * @return A BootConfig representing the native boot config object. + */ + static BootConfig getNativeBootConfig(Context ctx) { + BootConfig nativeBootConfig = new BootConfig(); + nativeBootConfig.readFromXML(ctx); + return nativeBootConfig; + } + /** * Validates a boot config's inputs against basic sanity tests. * @param config The BootConfig instance to validate. @@ -180,7 +192,9 @@ public JSONObject asJSON() { JSONObject config = new JSONObject(); config.put(REMOTE_ACCESS_CONSUMER_KEY, remoteAccessConsumerKey); config.put(OAUTH_REDIRECT_URI, oauthRedirectURI); - config.put(OAUTH_SCOPES, new JSONArray(Arrays.asList(oauthScopes))); + if (oauthScopes != null) { + config.put(OAUTH_SCOPES, new JSONArray(Arrays.asList(oauthScopes))); + } config.put(IS_LOCAL, isLocal); config.put(START_PAGE, startPage); config.put(ERROR_PAGE, errorPage); @@ -224,7 +238,12 @@ private void readFromXML(Context ctx) { final Resources res = ctx.getResources(); remoteAccessConsumerKey = res.getString(R.string.remoteAccessConsumerKey); oauthRedirectURI = res.getString(R.string.oauthRedirectURI); - oauthScopes = res.getStringArray(R.array.oauthScopes); + try { + oauthScopes = res.getStringArray(R.array.oauthScopes); + } catch (Resources.NotFoundException e) { + // oauthScopes is optional, leave it as null + oauthScopes = null; + } } /** @@ -237,11 +256,18 @@ private void parseBootConfig(JSONObject config) { // Required fields. remoteAccessConsumerKey = config.getString(REMOTE_ACCESS_CONSUMER_KEY); oauthRedirectURI = config.getString(OAUTH_REDIRECT_URI); - final JSONArray jsonScopes = config.getJSONArray(OAUTH_SCOPES); - oauthScopes = new String[jsonScopes.length()]; - for (int i = 0; i < oauthScopes.length; i++) { - oauthScopes[i] = jsonScopes.getString(i); + + // Optional oauthScopes field. + if (config.has(OAUTH_SCOPES)) { + final JSONArray jsonScopes = config.getJSONArray(OAUTH_SCOPES); + oauthScopes = new String[jsonScopes.length()]; + for (int i = 0; i < oauthScopes.length; i++) { + oauthScopes[i] = jsonScopes.getString(i); + } + } else { + oauthScopes = null; } + isLocal = config.getBoolean(IS_LOCAL); startPage = config.getString(START_PAGE); errorPage = config.getString(ERROR_PAGE); diff --git a/libs/test/SalesforceSDKTest/assets/www/bootconfig_emptyOauthScopes.json b/libs/test/SalesforceSDKTest/assets/www/bootconfig_emptyOauthScopes.json new file mode 100644 index 0000000000..a128a77a1c --- /dev/null +++ b/libs/test/SalesforceSDKTest/assets/www/bootconfig_emptyOauthScopes.json @@ -0,0 +1,11 @@ +{ + "remoteAccessConsumerKey": "3MVG9Iu66FKeHhINkB1l7xt7kR8czFcCTUhgoA8Ol2Ltf1eYHOU4SqQRSEitYFDUpqRWcoQ2.dBv_a1Dyu5xa", + "oauthRedirectURI": "testsfdc:///mobilesdk/detect/oauth/done", + "oauthScopes": [], + "isLocal": true, + "startPage": "index.html", + "errorPage": "error.html", + "shouldAuthenticate": true, + "attemptOfflineLoad": true +} + diff --git a/libs/test/SalesforceSDKTest/assets/www/bootconfig_noOauthScopes.json b/libs/test/SalesforceSDKTest/assets/www/bootconfig_noOauthScopes.json new file mode 100644 index 0000000000..c87a2c16b8 --- /dev/null +++ b/libs/test/SalesforceSDKTest/assets/www/bootconfig_noOauthScopes.json @@ -0,0 +1,10 @@ +{ + "remoteAccessConsumerKey": "3MVG9Iu66FKeHhINkB1l7xt7kR8czFcCTUhgoA8Ol2Ltf1eYHOU4SqQRSEitYFDUpqRWcoQ2.dBv_a1Dyu5xa", + "oauthRedirectURI": "testsfdc:///mobilesdk/detect/oauth/done", + "isLocal": true, + "startPage": "index.html", + "errorPage": "error.html", + "shouldAuthenticate": true, + "attemptOfflineLoad": true +} + diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java index a04ffac03f..e8b60fffa7 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java @@ -298,6 +298,28 @@ private void tryScopes(String[] scopes, String expectedScopeParamValue) throws U } } + private void tryLoginHint(String loginHint, String expectedLoginHintParamValue) throws URISyntaxException { + String callbackUrl = "sfdc://callback"; + + // Test with loginHint provided + URI authorizationUrl = OAuth2.getAuthorizationUrl(true, true, new URI(TestCredentials.LOGIN_URL), + TestCredentials.CLIENT_ID, callbackUrl, null, loginHint, null, "some-challenge", null); + HttpUrl url = HttpUrl.get(authorizationUrl); + boolean loginHintFound = false; + for (int i = 0, size = url.querySize(); i < size; i++) { + if (url.queryParameterName(i).equalsIgnoreCase("login_hint")) { + loginHintFound = true; + Assert.assertEquals("Wrong login hint value", expectedLoginHintParamValue, url.queryParameterValue(i)); + break; + } + } + if (expectedLoginHintParamValue == null) { + Assert.assertFalse("Login hint found when not expected", loginHintFound); + } else { + Assert.assertTrue("No login hint param found in query", loginHintFound); + } + } + /** * Testing getAuthorizationUrl with scopes. * @@ -305,7 +327,6 @@ private void tryScopes(String[] scopes, String expectedScopeParamValue) throws U */ @Test public void testGetAuthorizationUrlWithScopes() throws URISyntaxException { - //verify basic scopes present tryScopes(new String[]{"foo", "bar"}, "bar foo refresh_token"); @@ -317,7 +338,52 @@ public void testGetAuthorizationUrlWithScopes() throws URISyntaxException { //empty scopes -- should not find scopes tryScopes(new String[] {}, null); + + //null scopes -- should not find scopes + tryScopes(null, null); } + + /** + * Testing getAuthorizationUrl with loginHint parameter. + * + * @throws URISyntaxException See {@link URISyntaxException}. + */ + @Test + public void testGetAuthorizationUrlWithLoginHint() throws URISyntaxException { + //verify basic login hint present + tryLoginHint("user@org.com", "user@org.com"); + + //empty login hint -- should not find login hint + tryLoginHint("", null); + + //null login hint -- should not find login hint + tryLoginHint(null, null); + } + + /** + * Testing getAuthorizationUrl with custom displayType. + * + * @throws URISyntaxException See {@link URISyntaxException}. + */ + @Test + public void testGetAuthorizationUrlWithCustomDisplayType() throws URISyntaxException { + String callbackUrl = "sfdc://callback"; + String customDisplayType = "page"; + + URI authorizationUrl = OAuth2.getAuthorizationUrl(true, true, new URI(TestCredentials.LOGIN_URL), + TestCredentials.CLIENT_ID, callbackUrl, null, null, customDisplayType, "some-challenge", null); + HttpUrl url = HttpUrl.get(authorizationUrl); + + boolean displayFound = false; + for (int i = 0, size = url.querySize(); i < size; i++) { + if (url.queryParameterName(i).equalsIgnoreCase("display")) { + displayFound = true; + Assert.assertEquals("Wrong display value", customDisplayType, url.queryParameterValue(i)); + break; + } + } + Assert.assertTrue("display parameter should be present", displayFound); + } /** diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/BootConfigTest.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/BootConfigTest.java deleted file mode 100644 index 1d90f32183..0000000000 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/BootConfigTest.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) 2017-present, salesforce.com, inc. - * All rights reserved. - * Redistribution and use of this software in source and binary forms, with or - * without modification, are permitted provided that the following conditions - * are met: - * - Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * - Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - Neither the name of salesforce.com, inc. nor the names of its contributors - * may be used to endorse or promote products derived from this software without - * specific prior written permission of salesforce.com, inc. - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - */ -package com.salesforce.androidsdk.config; - -import android.content.Context; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.filters.SmallTest; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * Tests for BootConfig. - * - * @author khawkins - */ -@RunWith(AndroidJUnit4.class) -@SmallTest -public class BootConfigTest { - - private static final String BOOTCONFIG_ASSETS_PATH_PREFIX = "www" + System.getProperty("file.separator"); - private Context testContext; - - @Before - public void setUp() throws Exception { - testContext = InstrumentationRegistry.getInstrumentation().getContext(); - } - - @After - public void tearDown() throws Exception { - testContext = null; - } - - @Test - public void testNoBootConfig() { - try { - BootConfig.validateBootConfig(null); - Assert.fail("Validation should fail with no boot config."); - } catch (BootConfig.BootConfigException e) { - // Expected - } - } - - @Test - public void testAbsoluteStartPage() { - BootConfig config = BootConfig.getHybridBootConfig(testContext, BOOTCONFIG_ASSETS_PATH_PREFIX + "bootconfig_absoluteStartPage.json"); - validateBootConfig(config, "Validation should fail with absolute URL start page."); - } - - @Test - public void testRemoteDeferredAuthNoUnauthenticatedStartPage() { - BootConfig config = BootConfig.getHybridBootConfig(testContext, BOOTCONFIG_ASSETS_PATH_PREFIX + "bootconfig_remoteDeferredAuthNoUnauthenticatedStartPage.json"); - validateBootConfig(config, "Validation should fail with no unauthenticatedStartPage value in remote deferred auth."); - } - - @Test - public void testRelativeUnauthenticatedStartPage() { - BootConfig config = BootConfig.getHybridBootConfig(testContext, BOOTCONFIG_ASSETS_PATH_PREFIX + "bootconfig_relativeUnauthenticatedStartPage.json"); - validateBootConfig(config, "Validation should fail with relative unauthenticatedStartPage value."); - } - - private void validateBootConfig(BootConfig config, String errorMessage) { - Assert.assertNotNull("Boot config should not be null.", config); - try { - BootConfig.validateBootConfig(config); - Assert.fail(errorMessage); - } catch (BootConfig.BootConfigException e) { - // Expected - } - } -} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/BootConfigTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/BootConfigTest.kt new file mode 100644 index 0000000000..0f27f7aed8 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/BootConfigTest.kt @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2017-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.config + +import android.content.Context +import android.content.res.Resources +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.salesforce.androidsdk.R +import io.mockk.every +import io.mockk.mockk +import org.json.JSONException +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for BootConfig. + * + * @author khawkins + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +class BootConfigTest { + + private val bootconfigAssetsPathPrefix = "www${System.getProperty("file.separator")}" + private lateinit var testContext: Context + + @Before + fun setUp() { + testContext = InstrumentationRegistry.getInstrumentation().context + } + + @After + fun tearDown() { + } + + @Test + fun testNoBootConfig() { + try { + BootConfig.validateBootConfig(null) + fail("Validation should fail with no boot config.") + } catch (e: BootConfig.BootConfigException) { + // Expected + } + } + + @Test + fun testAbsoluteStartPage() { + val config = BootConfig.getHybridBootConfig( + testContext, + bootconfigAssetsPathPrefix + "bootconfig_absoluteStartPage.json" + ) + validateBootConfig(config, "Validation should fail with absolute URL start page.") + } + + @Test + fun testRemoteDeferredAuthNoUnauthenticatedStartPage() { + val config = BootConfig.getHybridBootConfig( + testContext, + bootconfigAssetsPathPrefix + "bootconfig_remoteDeferredAuthNoUnauthenticatedStartPage.json" + ) + validateBootConfig(config, "Validation should fail with no unauthenticatedStartPage value in remote deferred auth.") + } + + @Test + fun testRelativeUnauthenticatedStartPage() { + val config = BootConfig.getHybridBootConfig( + testContext, + bootconfigAssetsPathPrefix + "bootconfig_relativeUnauthenticatedStartPage.json" + ) + validateBootConfig(config, "Validation should fail with relative unauthenticatedStartPage value.") + } + + fun testBootConfigJsonWithOauthScopes() { + // Tests with bootconfig.json which has oauth scopes defined + val config = BootConfig.getHybridBootConfig( + testContext, + bootconfigAssetsPathPrefix + "bootconfig_absoluteStartPage.json" + ) + assertNotNull("Boot config should not be null.", config) + assertNotNull("OAuth scopes should not be null when specified in XML.", config.oauthScopes) + assertTrue("OAuth scopes should have at least one scope.", config.oauthScopes!!.isNotEmpty()) + } + + + @Test + fun testBootConfigJsonWithNoOauthScopes() { + val config = BootConfig.getHybridBootConfig( + testContext, + bootconfigAssetsPathPrefix + "bootconfig_noOauthScopes.json" + ) + assertNotNull("Boot config should not be null.", config) + assertNull("OAuth scopes should be null when not specified in JSON.", config.oauthScopes) + // Should validate successfully + BootConfig.validateBootConfig(config) + } + + @Test + fun testBootConfigJsonWithEmptyOauthScopes() { + val config = BootConfig.getHybridBootConfig( + testContext, + bootconfigAssetsPathPrefix + "bootconfig_emptyOauthScopes.json" + ) + assertNotNull("Boot config should not be null.", config) + assertNotNull("OAuth scopes should not be null when empty array specified in JSON.", config.oauthScopes) + assertEquals("OAuth scopes should be empty array when empty array specified in JSON.", 0, config.oauthScopes?.size) + // Should validate successfully + BootConfig.validateBootConfig(config) + } + + @Test + fun testBootConfigXmlWithOauthScopes() { + // Tests the default bootconfig.xml which has oauth scopes defined + val config = BootConfig.getNativeBootConfig(testContext) + assertNotNull("Boot config should not be null.", config) + assertNotNull("OAuth scopes should not be null when specified in XML.", config.oauthScopes) + assertTrue("OAuth scopes should have at least one scope.", config.oauthScopes!!.isNotEmpty()) + } + + @Test + fun testBootConfigXmlWithNoOauthScopes() { + // Create mock context and resources + val mockContext = mockk() + val mockResources = mockk() + + // Setup mock to return test values for strings + every { mockContext.resources } returns mockResources + every { mockResources.getString(R.string.remoteAccessConsumerKey) } returns "test_consumer_key" + every { mockResources.getString(R.string.oauthRedirectURI) } returns "test://redirect" + + // Simulate missing oauthScopes resource by throwing NotFoundException + every { mockResources.getStringArray(R.array.oauthScopes) } throws Resources.NotFoundException("oauthScopes not found") + + // Test reading from XML with missing scopes + val config = BootConfig.getNativeBootConfig(mockContext) + assertNotNull("Boot config should not be null.", config) + assertNull("OAuth scopes should be null when not specified in XML.", config.oauthScopes) + assertEquals("Consumer key should match.", "test_consumer_key", config.remoteAccessConsumerKey) + assertEquals("Redirect URI should match.", "test://redirect", config.oauthRedirectURI) + } + + @Test + fun testBootConfigXmlWithEmptyOauthScopes() { + // Create mock context and resources + val mockContext = mockk() + val mockResources = mockk() + + // Setup mock to return test values + every { mockContext.resources } returns mockResources + every { mockResources.getString(R.string.remoteAccessConsumerKey) } returns "test_consumer_key" + every { mockResources.getString(R.string.oauthRedirectURI) } returns "test://redirect" + + // Return an empty array for oauthScopes + every { mockResources.getStringArray(R.array.oauthScopes) } returns emptyArray() + + // Test reading from XML with empty scopes array + val config = BootConfig.getNativeBootConfig(mockContext) + assertNotNull("Boot config should not be null.", config) + assertNotNull("OAuth scopes should not be null when empty array specified in XML.", config.oauthScopes) + assertEquals("OAuth scopes should be empty array when empty array specified in XML.", 0, config.oauthScopes?.size) + assertEquals("Consumer key should match.", "test_consumer_key", config.remoteAccessConsumerKey) + assertEquals("Redirect URI should match.", "test://redirect", config.oauthRedirectURI) + } + + @Test + fun testAsJSONWithNoOauthScopes() { + // Test that asJSON properly handles missing oauth scopes + val config = BootConfig.getHybridBootConfig( + testContext, + bootconfigAssetsPathPrefix + "bootconfig_noOauthScopes.json" + ) + assertNotNull("Boot config should not be null.", config) + + val json = config.asJSON() + assertNotNull("JSON representation should not be null.", json) + assertFalse("JSON should not contain oauthScopes key when scopes are null.", json.has("oauthScopes")) + } + + @Test + fun testAsJSONWithEmptyOauthScopes() { + // Test that asJSON properly handles empty oauth scopes array + val config = BootConfig.getHybridBootConfig( + testContext, + bootconfigAssetsPathPrefix + "bootconfig_emptyOauthScopes.json" + ) + assertNotNull("Boot config should not be null.", config) + + val json = config.asJSON() + assertNotNull("JSON representation should not be null.", json) + assertTrue("JSON should contain oauthScopes key when scopes are empty array.", json.has("oauthScopes")) + try { + val scopes = json.getJSONArray("oauthScopes") + assertEquals("JSON oauthScopes should be empty array.", 0, scopes.length()) + } catch (e: JSONException) { + fail("Should be able to get oauthScopes as JSONArray: ${e.message}") + } + } + + private fun validateBootConfig(config: BootConfig, errorMessage: String) { + assertNotNull("Boot config should not be null.", config) + try { + BootConfig.validateBootConfig(config) + fail(errorMessage) + } catch (e: BootConfig.BootConfigException) { + // Expected + } + } +} +