diff --git a/.github/workflows/reusable-ui-test-workflow.yaml b/.github/workflows/reusable-ui-test-workflow.yaml index f29ff868b2..ee242a80c0 100644 --- a/.github/workflows/reusable-ui-test-workflow.yaml +++ b/.github/workflows/reusable-ui-test-workflow.yaml @@ -160,6 +160,8 @@ jobs: comment: true job_summary: true report_paths: 'test-results-authflowtester-ui-ios${{ inputs.ios }}-${{ steps.result_suffix.outputs.suffix }}.xml' + check_retries: false + group_suite: true - uses: codecov/codecov-action@v4 if: success() || failure() with: diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/DevConfig/AuthFlowTypesView.swift b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/DevConfig/AuthFlowTypesView.swift index 50d17ee022..d56c5d9251 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/DevConfig/AuthFlowTypesView.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/DevConfig/AuthFlowTypesView.swift @@ -27,35 +27,57 @@ import SwiftUI +// MARK: - JSON Import Labels +public struct AuthFlowTypesJSONKeys { + public static let useWebServerFlow = "useWebServerFlow" + public static let useHybridFlow = "useHybridFlow" +} + public struct AuthFlowTypesView: View { @State private var useWebServerFlow: Bool @State private var useHybridFlow: Bool - + @State private var showImportAlert: Bool = false + @State private var importJSONText: String = "" + public init() { _useWebServerFlow = State(initialValue: SalesforceManager.shared.useWebServerAuthentication) _useHybridFlow = State(initialValue: SalesforceManager.shared.useHybridAuthentication) } - + public var body: some View { VStack(alignment: .leading, spacing: 12) { - Text(SFSDKResourceUtils.localizedString("LOGIN_OPTIONS_AUTH_FLOW_TYPES_TITLE")) - .font(.headline) - .padding(.horizontal) + HStack { + Text(SFSDKResourceUtils.localizedString("LOGIN_OPTIONS_AUTH_FLOW_TYPES_TITLE")) + .font(.headline) + Spacer() + Button(action: { + importJSONText = "" + showImportAlert = true + }) { + Image(systemName: "square.and.arrow.down") + .font(.subheadline) + .foregroundColor(.blue) + } + .accessibilityIdentifier("importAuthFlowTypesButton") + } + .padding(.horizontal) VStack(alignment: .leading, spacing: 8) { Toggle(isOn: $useWebServerFlow) { Text(SFSDKResourceUtils.localizedString("LOGIN_OPTIONS_USE_WEB_SERVER_FLOW")) .font(.body) } + .accessibilityIdentifier("useWebServerFlowToggle") .onChange(of: useWebServerFlow) { _, newValue in SalesforceManager.shared.useWebServerAuthentication = newValue } .padding(.horizontal) - + Toggle(isOn: $useHybridFlow) { Text(SFSDKResourceUtils.localizedString("LOGIN_OPTIONS_USE_HYBRID_FLOW")) .font(.body) } + .accessibilityIdentifier("useHybridFlowToggle") .onChange(of: useHybridFlow) { _, newValue in SalesforceManager.shared.useHybridAuthentication = newValue } @@ -63,6 +85,33 @@ public struct AuthFlowTypesView: View { } } .padding(.vertical) + .alert("Import Auth Flow Types", isPresented: $showImportAlert) { + TextField("Paste JSON here", text: $importJSONText) + Button("Import") { + applyAuthFlowTypesFromJSON(importJSONText) + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Paste JSON with useWebServerFlow and useHybridFlow") + } + } + + // MARK: - Helper Methods + + internal func applyAuthFlowTypesFromJSON(_ jsonString: String) { + guard let jsonData = jsonString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + return + } + + if let webServerFlow = json[AuthFlowTypesJSONKeys.useWebServerFlow] as? Bool { + useWebServerFlow = webServerFlow + SalesforceManager.shared.useWebServerAuthentication = webServerFlow + } + if let hybridFlow = json[AuthFlowTypesJSONKeys.useHybridFlow] as? Bool { + useHybridFlow = hybridFlow + SalesforceManager.shared.useHybridAuthentication = hybridFlow + } } } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/DevConfig/LoginOptionsViewController.swift b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/DevConfig/LoginOptionsViewController.swift index 4be45e0181..c2b19a589b 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/DevConfig/LoginOptionsViewController.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/DevConfig/LoginOptionsViewController.swift @@ -69,6 +69,8 @@ public struct LoginOptionsView: View { } public var body: some View { + let isUITesting = ProcessInfo.processInfo.environment["IS_UI_TESTING"] == "1" + VStack(spacing: 0) { // Custom title bar with close button – trigger handler then dismiss so presenter can run its callback TitleBarView(title: SFSDKResourceUtils.localizedString("LOGIN_OPTIONS"), onDismiss: closeSheet) @@ -110,15 +112,18 @@ public struct LoginOptionsView: View { initiallyExpanded: false ) - Divider() - - // Simulate domain discovery – sets simulatedDomainDiscoveryResult so the next discovery navigation uses this result - DiscoveryResultEditor( - loginHost: $discoveryLoginHost, - userName: $discoveryUserName, - onUseForSimulation: handleSimulatedDomainDiscovery, - initiallyExpanded: false - ) + // Only show DiscoveryResultEditor during UI tests + if isUITesting { + Divider() + + // Simulate domain discovery – sets simulatedDomainDiscoveryResult so the next discovery navigation uses this result + DiscoveryResultEditor( + loginHost: $discoveryLoginHost, + userName: $discoveryUserName, + onUseForSimulation: handleSimulatedDomainDiscovery, + initiallyExpanded: false + ) + } } .padding(.bottom, 40) } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/AuthFlowTypesViewTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/AuthFlowTypesViewTests.swift index 0accfc5232..bed9fb0992 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/AuthFlowTypesViewTests.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/AuthFlowTypesViewTests.swift @@ -85,5 +85,121 @@ class AuthFlowTypesViewTests: XCTestCase { wait(for: [expectation], timeout: 2.0) } + + func testImportAuthFlowTypesFromJSON() { + // Set initial state + SalesforceManager.shared.useWebServerAuthentication = true + SalesforceManager.shared.useHybridAuthentication = true + + // Create the view + var view = AuthFlowTypesView() + + // Verify initial state + XCTAssertTrue(SalesforceManager.shared.useWebServerAuthentication, + "Web server authentication should initially be true") + XCTAssertTrue(SalesforceManager.shared.useHybridAuthentication, + "Hybrid authentication should initially be true") + + // Create JSON string + let json: [String: Any] = [ + AuthFlowTypesJSONKeys.useWebServerFlow: false, + AuthFlowTypesJSONKeys.useHybridFlow: false + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) else { + XCTFail("Failed to create JSON string") + return + } + + // Call the internal method to apply the JSON + view.applyAuthFlowTypesFromJSON(jsonString) + + // Verify the values were updated + XCTAssertFalse(SalesforceManager.shared.useWebServerAuthentication, + "Web server authentication should be false after import") + XCTAssertFalse(SalesforceManager.shared.useHybridAuthentication, + "Hybrid authentication should be false after import") + } + + func testImportAuthFlowTypesFromJSONPartialUpdate() { + // Set initial state + SalesforceManager.shared.useWebServerAuthentication = true + SalesforceManager.shared.useHybridAuthentication = true + + // Create the view + let view = AuthFlowTypesView() + + // Test importing only one value + let json: [String: Any] = [ + AuthFlowTypesJSONKeys.useWebServerFlow: false + // Note: useHybridFlow is not included + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) else { + XCTFail("Failed to create JSON string") + return + } + + // Call the internal method to apply the JSON + view.applyAuthFlowTypesFromJSON(jsonString) + + // Verify only the specified value was updated + XCTAssertFalse(SalesforceManager.shared.useWebServerAuthentication, + "Web server authentication should be false after import") + XCTAssertTrue(SalesforceManager.shared.useHybridAuthentication, + "Hybrid authentication should remain true (not in JSON)") + } + + func testAuthFlowTypesJSONKeys() { + // Test that the JSON keys are correctly defined + XCTAssertEqual(AuthFlowTypesJSONKeys.useWebServerFlow, "useWebServerFlow", + "useWebServerFlow key should match expected value") + XCTAssertEqual(AuthFlowTypesJSONKeys.useHybridFlow, "useHybridFlow", + "useHybridFlow key should match expected value") + } + + func testImportAuthFlowTypesFromInvalidJSON() { + // Set initial state + SalesforceManager.shared.useWebServerAuthentication = true + SalesforceManager.shared.useHybridAuthentication = true + + // Create the view + let view = AuthFlowTypesView() + + // Test with invalid JSON + let invalidJSON = "{ this is not valid JSON }" + + // Call the internal method with invalid JSON + view.applyAuthFlowTypesFromJSON(invalidJSON) + + // Verify nothing changed (method should handle invalid JSON gracefully) + XCTAssertTrue(SalesforceManager.shared.useWebServerAuthentication, + "Web server authentication should remain true after invalid JSON") + XCTAssertTrue(SalesforceManager.shared.useHybridAuthentication, + "Hybrid authentication should remain true after invalid JSON") + } + + func testImportAuthFlowTypesFromEmptyJSON() { + // Set initial state + SalesforceManager.shared.useWebServerAuthentication = true + SalesforceManager.shared.useHybridAuthentication = false + + // Create the view + let view = AuthFlowTypesView() + + // Test with empty JSON object + let emptyJSON = "{}" + + // Call the internal method with empty JSON + view.applyAuthFlowTypesFromJSON(emptyJSON) + + // Verify nothing changed + XCTAssertTrue(SalesforceManager.shared.useWebServerAuthentication, + "Web server authentication should remain unchanged after empty JSON") + XCTAssertFalse(SalesforceManager.shared.useHybridAuthentication, + "Hybrid authentication should remain unchanged after empty JSON") + } } diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/project.pbxproj b/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/project.pbxproj index 912cf14a06..024d21476d 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/project.pbxproj +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/project.pbxproj @@ -87,7 +87,6 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( overview.md, - ui_test_config.json, ); target = 4F8E4AF02ED13CE800DA7B7A /* AuthFlowTesterUITests */; }; diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester/ViewControllers/SessionDetailViewController.swift b/native/SampleApps/AuthFlowTester/AuthFlowTester/ViewControllers/SessionDetailViewController.swift index 178adf6852..1e1e810c15 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTester/ViewControllers/SessionDetailViewController.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester/ViewControllers/SessionDetailViewController.swift @@ -117,7 +117,10 @@ struct SessionDetailView: View { } .sheet(isPresented: $showMigrateRefreshToken) { NavigationView { - VStack { + VStack(spacing: 20) { + AuthFlowTypesView() + .padding(.top) + BootConfigEditor( title: "New App Configuration", buttonLabel: "Migrate refresh token", diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift index fcdcbdcea8..f2de559588 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift @@ -213,9 +213,11 @@ struct JwtDetailsData { /// and extract data (user credentials, OAuth configuration, JWT details) from the UI. class AuthFlowTesterMainPageObject { let app: XCUIApplication + let authFlowTypesPageObject: AuthFlowTypesPageObject init(testApp: XCUIApplication) { app = testApp + authFlowTypesPageObject = AuthFlowTypesPageObject(testApp: testApp) } func isShowing() -> Bool { @@ -266,20 +268,26 @@ class AuthFlowTesterMainPageObject { tap(swithToUserButton()) } - func changeAppConfig(appConfig: AppConfig, scopesToRequest: String = "") -> Bool { + func changeAppConfig(appConfig: AppConfig, scopesToRequest: String = "", useWebServerFlow: Bool, useHybridFlow: Bool) -> Bool { // Tap Change Key button to open the sheet tap(bottomBarChangeKeyButton()) - + + // Set auth flow types using the dedicated page object + authFlowTypesPageObject.setAuthFlowTypes( + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow + ) + // Build JSON config and import it let configJSON = buildConfigJSON(consumerKey: appConfig.consumerKey, redirectUri: appConfig.redirectUri, scopes: scopesToRequest) importConfig(configJSON) - + // Tap the migrate button tap(migrateRefreshTokenButton()) - + // Tap the allow button if it appears tapIfPresent(allowButton()) - + let alert = app.alerts["Migration Error"] if (alert.waitForExistence(timeout: UITestTimeouts.long)) { alert.buttons["OK"].tap() @@ -305,15 +313,15 @@ class AuthFlowTesterMainPageObject { private func importConfig(_ jsonString: String) { tap(importConfigButton()) - + // Wait for alert to appear let alert = importConfigAlert() _ = alert.waitForExistence(timeout: UITestTimeouts.long) - + // Type into the alert's text field let textField = importConfigTextField() textField.typeText(jsonString) - + tap(importAlertButton()) } @@ -433,7 +441,7 @@ class AuthFlowTesterMainPageObject { private func swithToUserButton() -> XCUIElement { return app.buttons["Switch to User"] } - + // MARK: - Actions private func tap(_ element: XCUIElement) { @@ -450,7 +458,7 @@ class AuthFlowTesterMainPageObject { private func setTextField(_ textField: XCUIElement, value: String) { _ = textField.waitForExistence(timeout: UITestTimeouts.long) textField.tap() - + // Clear any existing text if let currentValue = textField.value as? String, !currentValue.isEmpty { textField.tap() @@ -460,10 +468,10 @@ class AuthFlowTesterMainPageObject { textField.typeText(XCUIKeyboardKey.delete.rawValue) } } - + textField.typeText(value) } - + // MARK: - Data Extraction Methods func getUserCredentials() -> UserCredentialsData { diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTypesPageObject.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTypesPageObject.swift new file mode 100644 index 0000000000..54e3593aa9 --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTypesPageObject.swift @@ -0,0 +1,95 @@ +/* + AuthFlowTypesPageObject.swift + AuthFlowTesterUITests + + Copyright (c) 2026-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. + */ + +import Foundation +import XCTest +import SalesforceSDKCore + +/// Page object for interacting with the AuthFlowTypesView (auth flow switches) during UI tests. +/// Can be used from both LoginOptionsView and the refresh token migration sheet. +class AuthFlowTypesPageObject { + let app: XCUIApplication + + init(testApp: XCUIApplication) { + app = testApp + } + + /// Sets the auth flow types (useWebServerFlow and useHybridFlow) using JSON import. + func setAuthFlowTypes(useWebServerFlow: Bool, useHybridFlow: Bool) { + // Wait for the import button to be ready + _ = importAuthFlowTypesButton().waitForExistence(timeout: UITestTimeouts.long) + + // Build and import JSON + let authFlowTypesJSON = buildAuthFlowTypesJSON( + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow + ) + importAuthFlowTypes(authFlowTypesJSON) + } + + // MARK: - Private Helpers + + private func buildAuthFlowTypesJSON(useWebServerFlow: Bool, useHybridFlow: Bool) -> String { + let config: [String: Bool] = [ + AuthFlowTypesJSONKeys.useWebServerFlow: useWebServerFlow, + AuthFlowTypesJSONKeys.useHybridFlow: useHybridFlow + ] + guard let jsonData = try? JSONSerialization.data(withJSONObject: config, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return "{}" + } + return jsonString + } + + private func importAuthFlowTypes(_ jsonString: String) { + tap(importAuthFlowTypesButton()) + + // Wait for alert to appear + let alert = app.alerts["Import Auth Flow Types"] + _ = alert.waitForExistence(timeout: UITestTimeouts.long) + + // Type into the alert's text field + let textField = alert.textFields.firstMatch + textField.typeText(jsonString) + + // Tap Import button + alert.buttons["Import"].tap() + } + + // MARK: - UI Element Accessors + + private func importAuthFlowTypesButton() -> XCUIElement { + return app.buttons["importAuthFlowTypesButton"] + } + + // MARK: - Actions + + private func tap(_ element: XCUIElement) { + _ = element.waitForExistence(timeout: UITestTimeouts.long) + element.tap() + } +} diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginOptionsPageObject.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginOptionsPageObject.swift index 735b1b29ee..7753b711ba 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginOptionsPageObject.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginOptionsPageObject.swift @@ -33,9 +33,11 @@ import SalesforceSDKCore /// Use after navigating to Login Options from the login screen (e.g. via Settings → Login Options). class LoginOptionsPageObject { let app: XCUIApplication + let authFlowTypesPageObject: AuthFlowTypesPageObject init(testApp: XCUIApplication) { app = testApp + authFlowTypesPageObject = AuthFlowTypesPageObject(testApp: testApp) } /// Configures login options: flow switches, static/dynamic app config, and discovery result. @@ -50,8 +52,11 @@ class LoginOptionsPageObject { discoveryLoginHost: String, discoveryUsername: String, ) -> Void { - setSwitchField(useWebServerFlowSwitch(), value: useWebServerFlow) - setSwitchField(useHybridSwitch(), value: useHybridFlow) + // Set auth flow types using the dedicated page object + authFlowTypesPageObject.setAuthFlowTypes( + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow + ) if let staticAppConfig = staticAppConfig { let configJSON = buildConfigJSON(consumerKey: staticAppConfig.consumerKey, redirectUri: staticAppConfig.redirectUri, scopes: staticScopes) @@ -126,14 +131,6 @@ class LoginOptionsPageObject { // MARK: - UI Element Accessors (LoginOptionsView) - private func useWebServerFlowSwitch() -> XCUIElement { - return app.switches["Use Web Server Flow"] - } - - private func useHybridSwitch() -> XCUIElement { - return app.switches["Use Hybrid Flow"] - } - /// Returns the import button for either the static or dynamic configuration section. private func importConfigButton(useStaticConfiguration: Bool = true) -> XCUIElement { let buttons = app.buttons.matching(identifier: "importConfigButton") @@ -155,15 +152,4 @@ class LoginOptionsPageObject { _ = element.waitForExistence(timeout: UITestTimeouts.long) element.tap() } - - private func setSwitchField(_ switchField: XCUIElement, value: Bool) { - _ = switchField.waitForExistence(timeout: UITestTimeouts.long) - - // Switch values are "0" (off) or "1" (on) in XCTest - let currentValue = (switchField.value as? String) == "1" - - if currentValue != value { - tap(switchField) - } - } } diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift index 1d219a95d2..760027d8d9 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift @@ -50,6 +50,14 @@ class LoginPageObject { return advancedAuthCloseButton().waitForExistence(timeout: UITestTimeouts.short) } + func isShowingInvalidClientIdError() -> Bool { + return invalidClientIdText().waitForExistence(timeout: UITestTimeouts.long) + } + + func isShowingUnexpectedOauthError() -> Bool { + return unexpectedOauthErrorText().waitForExistence(timeout: UITestTimeouts.long) + } + func closeAdvancedAuth() -> Void { tap(advancedAuthCloseButton()) tap(hostRow(host: "Production")) @@ -174,6 +182,14 @@ class LoginPageObject { private func toolbarDoneButton() -> XCUIElement { return app.toolbars["Toolbar"].buttons["Done"] } + + private func invalidClientIdText() -> XCUIElement { + return app.staticTexts["error=invalid_client_id&error_description=client%20identifier%20invalid"] + } + + private func unexpectedOauthErrorText() -> XCUIElement { + return app.staticTexts["OAUTH_APPROVAL_ERROR_GENERIC : An unexpected error has occurred during authentication. Please try again."] + } private func usernameFieldLabel() -> XCUIElement { return app.staticTexts["Username"] diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/ECALoginTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/ECALoginTests.swift index 3ad8529ab1..cd2b0b2520 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/ECALoginTests.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/ECALoginTests.swift @@ -59,7 +59,7 @@ class ECALoginTests: BaseAuthFlowTester { } /// Login with ECA JWT using subset of scopes and web server flow. - func testECAJwt_SubsetScopes_NotHybrid() throws { + func testECAJwt_SubsetScopes() throws { launchLoginAndValidate(staticAppConfigName: .ecaJwt, staticScopeSelection: .subset) } @@ -67,4 +67,17 @@ class ECALoginTests: BaseAuthFlowTester { func testECAJwt_AllScopes() throws { launchLoginAndValidate(staticAppConfigName: .ecaJwt, staticScopeSelection: .all) } + + // MARK: - Negative testing + + /// Login with invalid client id in dynamic configuration + func testDynamicConfigurationWithInvalidClientId() throws { + launchAndLogin(loginHost: .regularAuth, user: .first, staticAppConfigName: .ecaOpaque, dynamicAppConfigName: .invalid) + } + + /// Login with invalid scope in dynamic configuration + func testDynamicConfigurationWithInvalidScope() throws { + launchAndLogin(loginHost: .regularAuth, user: .first, staticAppConfigName: .ecaOpaque, dynamicAppConfigName: .ecaJwt, dynamicScopeSelection: .invalid) + } + } diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LegacyLoginTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LegacyLoginTests.swift index 6d189d7af2..40c7fd8ad8 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LegacyLoginTests.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LegacyLoginTests.swift @@ -30,79 +30,55 @@ import XCTest /// Tests for legacy login flows including: /// - Connected App (CA) configurations (traditional OAuth connected apps) /// - User agent flow tests -/// - Non-hybrid flow tests /// - Default, subset, and all scope variations +/// - Hybrid flow (default behavior) +/// +/// For non-hybrid flow tests, see LegacyLoginTestsNotHybrid which extends this class. /// /// NB: Tests use the first user from ui_test_config.json /// class LegacyLoginTests: BaseAuthFlowTester { + // MARK: - Test Configuration + + /// Returns whether to use hybrid flow for tests. + /// Subclasses can override this to test non-hybrid flows. + func useHybridFlow() -> Bool { + return true + } + // MARK: - CA Web Server Flow Tests /// Login with CA opaque using default scopes and web server flow. func testCAOpaque_DefaultScopes_WebServerFlow() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque) + launchLoginAndValidate(staticAppConfigName: .caOpaque, useHybridFlow: useHybridFlow()) } /// Login with CA opaque using subset of scopes and web server flow. func testCAOpaque_SubsetScopes_WebServerFlow() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .subset, useHybridFlow: false) + launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .subset, useHybridFlow: useHybridFlow()) } /// Login with CA opaque using all scopes and web server flow. func testCAOpaque_AllScopes_WebServerFlow() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .all) - } - - // MARK: - CA Non-hybrid Web Server Flow Tests - - /// Login with CA opaque using default scopes and (non-hybrid) web server flow. - func testCAOpaque_DefaultScopes_WebServerFlow_NotHybrid() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, useHybridFlow: false) - } - - /// Login with CA opaque using subset of scopes and (non-hybrid) web server flow. - func testCAOpaque_SubsetScopes_WebServerFlow_NotHybrid() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .subset, useHybridFlow: false) - } - - /// Login with CA opaque using all scopes and (non-hybrid) web server flow. - func testCAOpaque_AllScopes_WebServerFlow_NotHybrid() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .all, useHybridFlow: false) + launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .all, useHybridFlow: useHybridFlow()) } // MARK: - CA User Agent Flow Tests /// Login with CA opaque using default scopes and user agent flow. func testCAOpaque_DefaultScopes_UserAgentFlow() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, useWebServerFlow: false) + launchLoginAndValidate(staticAppConfigName: .caOpaque, useWebServerFlow: false, useHybridFlow: useHybridFlow()) } /// Login with CA opaque using subset of scopes and user agent flow. func testCAOpaque_SubsetScopes_UserAgentFlow() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .subset, useWebServerFlow: false) + launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .subset, useWebServerFlow: false, useHybridFlow: useHybridFlow()) } /// Login with CA opaque using all scopes and user agent flow. func testCAOpaque_AllScopes_UserAgentFlow() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .all, useWebServerFlow: false) - } - - // MARK: - CA Non-hybrid User Agent Flow Tests - - /// Login with CA opaque using default scopes and (non-hybrid) user agent flow. - func testCAOpaque_DefaultScopes_UserAgentFlow_NotHybrid() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, useWebServerFlow: false, useHybridFlow: false) - } - - /// Login with CA opaque using subset of scopes and (non-hybrid) user agent flow. - func testCAOpaque_SubsetScopes_UserAgentFlow_NotHybrid() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .subset, useWebServerFlow: false, useHybridFlow: false) - } - - /// Login with CA opaque using all scopes and (non-hybrid) user agent flow. - func testCAOpaque_AllScopes_UserAgentFlow_NotHybrid() throws { - launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .all, useWebServerFlow: false, useHybridFlow: false) + launchLoginAndValidate(staticAppConfigName: .caOpaque, staticScopeSelection: .all, useWebServerFlow: false, useHybridFlow: useHybridFlow()) } } diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LegacyLoginTestsNotHybrid.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LegacyLoginTestsNotHybrid.swift new file mode 100644 index 0000000000..271b24042a --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LegacyLoginTestsNotHybrid.swift @@ -0,0 +1,47 @@ +/* + LegacyLoginTestsNotHybrid.swift + AuthFlowTesterUITests + + Copyright (c) 2026-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. + */ + +import XCTest + +/// Tests for legacy login flows using non-hybrid authentication. +/// Extends LegacyLoginTests and runs the same test cases with useHybridFlow set to false. +/// +/// Non-hybrid flow means the app does not receive front-door session cookies (SIDs) +/// for Lightning, Visualforce, and Content domains during authentication. +/// +/// NB: Tests use the first user from ui_test_config.json +/// +class LegacyLoginTestsNotHybrid: LegacyLoginTests { + + // MARK: - Test Configuration Override + + /// Returns false to test non-hybrid flows. + /// Overrides the parent class implementation. + override func useHybridFlow() -> Bool { + return false + } +} diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift index bfba5dc7cf..7b1d0d873b 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift @@ -312,9 +312,9 @@ class MultiUserLoginTests: BaseAuthFlowTester { // MARK: - Token Revocation Tests - /// Revoke user's access token (who uses dynamic config) and verify other user is unaffected. + /// Revoke access for user with dynamic config and verify other user is unaffected. /// Tests token isolation when one user uses dynamic consumer key selection. - func testRevokeUserWithDynamicConfig_OtherUserUnaffected() throws { + func testRevokeAccessForUserWithDynamicConfig_OtherUserUnaffected() throws { // Login User A with static config launchAndLogin( loginHost: .regularAuth, @@ -379,9 +379,9 @@ class MultiUserLoginTests: BaseAuthFlowTester { logout() } - /// Revoke CA user's access token and verify ECA user is unaffected. + /// Revoke access for CA user and verify ECA user is unaffected. /// Tests token isolation between users with different app types (CA vs ECA). - func testDifferentAppTypes_RevokeCaUser_EcaUserUnaffected() throws { + func testDifferentAppTypes_RevokeAccessForCaUser_EcaUserUnaffected() throws { // Login User A with CA Opaque launchAndLogin( loginHost: .regularAuth, @@ -447,4 +447,94 @@ class MultiUserLoginTests: BaseAuthFlowTester { // Logout second user logout() } + + // MARK: - User Logout Tests + + /// Logout user with dynamic config and verify other user is unaffected. + /// Tests that logging out one user automatically switches to the other user. + func testLogoutUserWithDynamicConfig_OtherUserUnaffected() throws { + // Login User A with static config + launchAndLogin( + loginHost: .regularAuth, + user: .fourth, + staticAppConfigName: .ecaOpaque + ) + + // Get User A credentials + let userACredentials = getUserCredentials() + + // Login User B with dynamic config (overrides consumer key at runtime) + loginOtherUserAndValidate( + loginHost: .regularAuth, + user: .fifth, + staticAppConfigName: .ecaOpaque, + dynamicAppConfigName: .ecaJwt + ) + + // Logout User B (should automatically switch to User A) + logout() + + // Verify we're automatically on User A after User B logout + let currentUserCredentials = getUserCredentials() + XCTAssertEqual( + currentUserCredentials.username, + userACredentials.username, + "Should automatically be on User A after User B logout" + ) + + // Verify User A's credentials are intact + XCTAssertEqual( + currentUserCredentials.accessToken, + userACredentials.accessToken, + "User A's access token should be unchanged after User B logout" + ) + + // Make API call for User A (should succeed) + XCTAssertTrue(makeRestRequest(), "User A's API call should succeed") + } + + /// Logout CA user and verify ECA user is unaffected. + /// Tests that logging out one user automatically switches to the other user with different app types. + func testDifferentAppTypes_LogoutCaUser_EcaUserUnaffected() throws { + // Login User A with CA Opaque + launchAndLogin( + loginHost: .regularAuth, + user: .fourth, + staticAppConfigName: .caOpaque + ) + + // Login User B with ECA Opaque + loginOtherUserAndValidate( + loginHost: .regularAuth, + user: .fifth, + staticAppConfigName: .ecaOpaque + ) + + // Get User B credentials + let userBCredentials = getUserCredentials() + + // Switch to User A + switchToUser(loginHost: .regularAuth, user: .fourth) + + // Logout User A (should automatically switch to User B) + logout() + + // Verify we're automatically on User B after User A logout + let currentUserCredentials = getUserCredentials() + XCTAssertEqual( + currentUserCredentials.username, + userBCredentials.username, + "Should automatically be on User B after User A logout" + ) + + // Verify User B's credentials are intact + XCTAssertEqual( + currentUserCredentials.accessToken, + userBCredentials.accessToken, + "User B's access token should be unchanged after User A logout" + ) + + // Make API call for User B (should succeed) + XCTAssertTrue(makeRestRequest(), "User B's API call should succeed") + } } diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/RefreshTokenMigrationTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/RefreshTokenMigrationTests.swift index e644e5e425..f8e0b1b2e6 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/RefreshTokenMigrationTests.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/RefreshTokenMigrationTests.swift @@ -118,6 +118,41 @@ class RefreshTokenMigrationTests: BaseAuthFlowTester { ) } + // MARK: - Migration with auth flow type change (user agent to web server flow) + + + // Migrate from CA (user agent) to ECA (web server) + func testMigrateCAUserAgentToECAWebServer() throws { + launchAndLogin( + loginHost: .regularAuth, + user:.second, + staticAppConfigName: .caOpaque, + useWebServerFlow: false + ) + migrateAndValidate( + loginHost: .regularAuth, + staticAppConfigName: .caOpaque, + migrationAppConfigName: .ecaOpaque, + migrationUseWebServerFlow: true + ) + } + + // Migrate from CA (user agent) to Beacon (web server) + func testMigrateCAUserAgentToBeaconWebServer() throws { + launchAndLogin( + loginHost: .regularAuth, + user:.second, + staticAppConfigName: .caOpaque, + useWebServerFlow: false + ) + migrateAndValidate( + loginHost: .regularAuth, + staticAppConfigName: .caOpaque, + migrationAppConfigName: .beaconOpaque, + migrationUseWebServerFlow: true + ) + } + // MARK: - Cross-App Migrations with rollbacks /// Migrate from CA to ECA and back to CA diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift index 39b49294f9..49f3a34333 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift @@ -34,6 +34,7 @@ class BaseAuthFlowTester: XCTestCase { // App Pages private var loginPage: LoginPageObject! private var mainPage: AuthFlowTesterMainPageObject! + private var logoutAtTearDown: Bool = true // Test configuration private let testConfig = UITestConfigUtils.shared @@ -44,7 +45,9 @@ class BaseAuthFlowTester: XCTestCase { } override func tearDown() { - logout() + if (logoutAtTearDown) { + logout() + } super.tearDown() } @@ -56,8 +59,9 @@ class BaseAuthFlowTester: XCTestCase { func launch() { app = XCUIApplication() - // Note: Environment variables are configured in AuthFlowTester.xctestplan - // (AutomaticTextCompletionEnabled=0) + // Set environment variable to indicate we're running UI tests + // This is used to show/hide certain UI elements like DiscoveryResultEditor + app.launchEnvironment["IS_UI_TESTING"] = "1" loginPage = LoginPageObject(testApp: app) mainPage = AuthFlowTesterMainPageObject(testApp: app) @@ -125,15 +129,31 @@ class BaseAuthFlowTester: XCTestCase { let loginHostToUse = useWelcomeDiscovery ? "welcome.salesforce.com/discovery" : hostConfig.urlNoProtocol loginPage.configureLoginHost(host: loginHostToUse) + // Invalid app config + if (dynamicAppConfigName == .invalid || (dynamicAppConfigName == nil && staticAppConfigName == .invalid)) { + XCTAssertTrue(loginPage.isShowingInvalidClientIdError(), "Login page should show invalid client id error") + logoutAtTearDown = false + return + } + + // Welcome login if (useWelcomeDiscovery) { XCTAssertTrue(loginPage.hasFilledUsernameField(username: userConfig.username), "Login page should have pre-filled username") loginPage.performWelcomeLogin(password: userConfig.password) - } else { - if (loginHost == .regularAuth) { - loginPage.performLogin(username: userConfig.username, password: userConfig.password) - } else { - loginPage.performAdvancedLogin(username: userConfig.username, password: userConfig.password) - } + } + // Regular auth + else if (loginHost == .regularAuth) { + loginPage.performLogin(username: userConfig.username, password: userConfig.password) + } + // Advanced auth + else if (loginHost == .advancedAuth) { + loginPage.performAdvancedLogin(username: userConfig.username, password: userConfig.password) + } + + // Invalid scope + if (dynamicScopeSelection == .invalid || (dynamicAppConfig == nil && staticScopeSelection == .invalid)) { + XCTAssertTrue(loginPage.isShowingUnexpectedOauthError(), "Screen should show OAuth Error") + logoutAtTearDown = false } } @@ -422,28 +442,30 @@ class BaseAuthFlowTester: XCTestCase { /// - staticScopeSelection: The scope selection for static configuration. Defaults to `.empty`. /// - migrationAppConfigName: The app configuration to migrate to. /// - migrationScopeSelection: The scope selection for the migration target. Defaults to `.empty`. - /// - useWebServerFlow: Whether to use web server OAuth flow. Defaults to `true`. - /// - useHybridFlow: Whether to use hybrid authentication flow. Defaults to `true`. + /// - migrationUseWebServerFlow: Whether to use web server OAuth flow for migration. Defaults to `true`. + /// - migrationUseHybridFlow: Whether to use hybrid authentication flow for migration. Defaults to `true`. func migrateAndValidate( loginHost: KnownLoginHostConfig, staticAppConfigName: KnownAppConfig, staticScopeSelection: ScopeSelection = .empty, migrationAppConfigName: KnownAppConfig, migrationScopeSelection: ScopeSelection = .empty, - useWebServerFlow: Bool = true, - useHybridFlow: Bool = true + migrationUseWebServerFlow: Bool = true, + migrationUseHybridFlow: Bool = true, ) { // Get original credentials before migration let originalUserCredentials = mainPage.getUserCredentials() // Get current user let user = getKnownUserConfig(loginHost: loginHost, byUsername: originalUserCredentials.username) - - + + // Migrate refresh token migrateRefreshToken( appConfigName: migrationAppConfigName, - scopeSelection: migrationScopeSelection + scopeSelection: migrationScopeSelection, + useWebServerFlow: migrationUseWebServerFlow, + useHybridFlow: migrationUseHybridFlow ) // Validate after migration @@ -454,8 +476,8 @@ class BaseAuthFlowTester: XCTestCase { staticScopeSelection: staticScopeSelection, userAppConfigName: migrationAppConfigName, userScopeSelection: migrationScopeSelection, - useWebServerFlow: useWebServerFlow, - useHybridFlow: useHybridFlow + useWebServerFlow: migrationUseWebServerFlow, + useHybridFlow: migrationUseHybridFlow ) // Making sure the refresh token changed @@ -599,12 +621,14 @@ class BaseAuthFlowTester: XCTestCase { private func migrateRefreshToken( appConfigName: KnownAppConfig, - scopeSelection: ScopeSelection + scopeSelection: ScopeSelection, + useWebServerFlow: Bool, + useHybridFlow: Bool ) { let appConfig = getAppConfig(named: appConfigName) let scopesToRequest = testConfig.getScopesToRequest(for: appConfig, scopeSelection) - - XCTAssert(mainPage.changeAppConfig(appConfig: appConfig, scopesToRequest: scopesToRequest), "Failed to migrate refresh token") + + XCTAssert(mainPage.changeAppConfig(appConfig: appConfig, scopesToRequest: scopesToRequest, useWebServerFlow: useWebServerFlow, useHybridFlow: useHybridFlow), "Failed to migrate refresh token") } /// Asserts that the main page is loaded and showing. diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/UITestConfigUtils.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/UITestConfigUtils.swift index cba9d74bf9..9cfc1f72a5 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/UITestConfigUtils.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/UITestConfigUtils.swift @@ -66,9 +66,10 @@ enum TestConfigError: Error, CustomStringConvertible { // MARK: - ScopeSelection enum ScopeSelection { - case empty // will not send scopes param - should be granted all the scopes defined on the server - case all // will send all the scopes defined in ui_test_config.json - case subset // will send a subset of the scopes defined in ui_test_config.json + case empty // Will not send scopes param - should be granted all the scopes defined on the server + case all // Will send all the scopes defined in ui_test_config.json + case subset // Will send a subset of the scopes defined in ui_test_config.json (all except sfap_api) + case invalid // Will send "invalid_scope" for negative testing scenarios } // MARK: - Configured Users @@ -97,6 +98,7 @@ enum KnownAppConfig: String { case beaconJwt = "beacon_jwt" case caOpaque = "ca_opaque" case caJwt = "ca_jwt" + case invalid = "invalid" // Returns hard-coded invalid app config for negative testing } // MARK: - Configuration Models @@ -293,8 +295,24 @@ class UITestConfigUtils { } } - /// Returns an app by its name or throws an error if not found or not configured + /// Returns an app by its name or throws an error if not found or not configured. + /// + /// - Parameter name: The known app configuration name to retrieve. + /// - Returns: The app configuration for the specified name. + /// - Throws: `TestConfigError.appNotFound` if the app doesn't exist in ui_test_config.json, + /// or `TestConfigError.appNotConfigured` if the app has an empty consumer key. + /// - Note: For `.invalid`, returns a hard-coded invalid configuration for negative testing scenarios. func getApp(named name: KnownAppConfig) throws -> AppConfig { + // Return hard-coded invalid app config for negative testing scenarios + if name == .invalid { + return AppConfig( + name: "invalid", + consumerKey: "invalid_consumer_key", + redirectUri: "invalid://callback", + scopes: "invalid_scope" + ) + } + guard let app = config?.apps.first(where: { $0.name == name.rawValue }) else { throw TestConfigError.appNotFound(name.rawValue) } @@ -304,21 +322,36 @@ class UITestConfigUtils { return app } - /// Returns scopes to request + /// Returns scopes to request based on the scope selection strategy. + /// + /// - Parameters: + /// - appConfig: The app configuration containing available scopes. + /// - scopesParam: The scope selection strategy to apply. + /// - Returns: A space-separated string of scopes to request, or empty string for `.empty`, + /// or "invalid_scope" for `.invalid` testing scenarios. func getScopesToRequest(for appConfig: AppConfig, _ scopesParam: ScopeSelection) -> String { switch(scopesParam) { case .empty: return "" - case .subset: return removeScope(scopes: appConfig.scopes, scopeToRemove: "sfap_api") // that assumes the selected ca/eca/beacon has the sfap_api scope + case .subset: return removeScope(scopes: appConfig.scopes, scopeToRemove: "sfap_api") // Assumes the app has sfap_api scope case .all: return appConfig.scopes + case .invalid: return "invalid_scope" // For negative testing } } - /// Returns expected scopes granted + /// Returns expected scopes that should be granted based on the scope selection. + /// + /// - Parameters: + /// - appConfig: The app configuration containing available scopes. + /// - scopeSelection: The scope selection strategy that was used during login. + /// - Returns: A space-separated string of scopes expected to be granted. + /// Returns empty string for `.invalid` as invalid scopes should not be granted. + /// - Note: For `.empty`, assumes scopes in ui_test_config.json match server configuration. func getExpectedScopesGranted(for appConfig:AppConfig, _ scopeSelection: ScopeSelection) -> String { switch(scopeSelection) { - case .empty: return appConfig.scopes // that assumes the scopes in ui_test_config.json match the server config - case .subset: return removeScope(scopes: appConfig.scopes, scopeToRemove: "sfap_api") // that assumes the selected ca/eca/beacon has the sfap_api scope + case .empty: return appConfig.scopes // Assumes scopes in config match server + case .subset: return removeScope(scopes: appConfig.scopes, scopeToRemove: "sfap_api") // Assumes app has sfap_api scope case .all: return appConfig.scopes + case .invalid: return "" // Invalid scopes should not be granted } } } diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/overview.md b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/overview.md index 913e4248a4..da1878ed86 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/overview.md +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/overview.md @@ -6,8 +6,9 @@ This document provides an overview of all UI tests in the AuthFlowTester test su | Class | Description | |-------|-------------| -| `LegacyLoginTests` | Tests for legacy login flows (CA, user agent flow, non-hybrid flow) with default, subset, and all scopes | -| `ECALoginTests` | Tests for External Client App (ECA) login flows | +| `LegacyLoginTests` | Tests for legacy login flows (CA, user agent flow, hybrid flow) with default, subset, and all scopes | +| `LegacyLoginTestsNotHybrid` | Tests for legacy login flows (CA, user agent flow, non-hybrid flow) - extends LegacyLoginTests | +| `ECALoginTests` | Tests for External Client App (ECA) login flows including negative testing with invalid configurations | | `BeaconLoginTests` | Tests for Beacon app login flows (using regular_auth login host) | | `AdvancedAuthBeaconLoginTests` | Tests for Beacon app login flows (using advanced_auth login host) | | `WelcomeLoginTests` | Tests for welcome (domain discovery) login flows using simulated domain discovery | @@ -20,37 +21,46 @@ This document provides an overview of all UI tests in the AuthFlowTester test su ## Login Tests -### LegacyLoginTests (12 tests) +### LegacyLoginTests (6 tests) -Tests for Connected App (CA) configurations with default, subset, and all scopes, including user agent flow and non-hybrid flow options. +Tests for Connected App (CA) configurations with default, subset, and all scopes using hybrid authentication flow. Tests both web server and user agent OAuth flows. | Test Name | App Config | Scopes | Flow | Hybrid | |-----------|------------|--------|------|--------| | `testCAOpaque_DefaultScopes_WebServerFlow` | CA Opaque | Default | Web Server | Yes | -| `testCAOpaque_SubsetScopes_WebServerFlow` | CA Opaque | Subset | Web Server | No | +| `testCAOpaque_SubsetScopes_WebServerFlow` | CA Opaque | Subset | Web Server | Yes | | `testCAOpaque_AllScopes_WebServerFlow` | CA Opaque | All | Web Server | Yes | -| `testCAOpaque_DefaultScopes_WebServerFlow_NotHybrid` | CA Opaque | Default | Web Server | No | -| `testCAOpaque_SubsetScopes_WebServerFlow_NotHybrid` | CA Opaque | Subset | Web Server | No | -| `testCAOpaque_AllScopes_WebServerFlow_NotHybrid` | CA Opaque | All | Web Server | No | | `testCAOpaque_DefaultScopes_UserAgentFlow` | CA Opaque | Default | User Agent | Yes | | `testCAOpaque_SubsetScopes_UserAgentFlow` | CA Opaque | Subset | User Agent | Yes | | `testCAOpaque_AllScopes_UserAgentFlow` | CA Opaque | All | User Agent | Yes | -| `testCAOpaque_DefaultScopes_UserAgentFlow_NotHybrid` | CA Opaque | Default | User Agent | No | -| `testCAOpaque_SubsetScopes_UserAgentFlow_NotHybrid` | CA Opaque | Subset | User Agent | No | -| `testCAOpaque_AllScopes_UserAgentFlow_NotHybrid` | CA Opaque | All | User Agent | No | -### ECALoginTests (6 tests) +### LegacyLoginTestsNotHybrid (6 tests) -Tests for External Client App (ECA) configurations using web server flow with hybrid auth. +Tests for Connected App (CA) configurations with non-hybrid authentication flow. Extends `LegacyLoginTests` and runs the same tests with `useHybridFlow` set to false. Non-hybrid flow means the app does not receive front-door session cookies (SIDs) during authentication. -| Test Name | App Config | Scopes | -|-----------|------------|--------| -| `testECAOpaque_DefaultScopes` | ECA Opaque | Default | -| `testECAOpaque_SubsetScopes` | ECA Opaque | Subset | -| `testECAOpaque_AllScopes` | ECA Opaque | All | -| `testECAJwt_DefaultScopes` | ECA JWT | Default | -| `testECAJwt_SubsetScopes_NotHybrid` | ECA JWT | Subset | -| `testECAJwt_AllScopes` | ECA JWT | All | +| Test Name | App Config | Scopes | Flow | Hybrid | +|-----------|------------|--------|------|--------| +| `testCAOpaque_DefaultScopes_WebServerFlow` | CA Opaque | Default | Web Server | No | +| `testCAOpaque_SubsetScopes_WebServerFlow` | CA Opaque | Subset | Web Server | No | +| `testCAOpaque_AllScopes_WebServerFlow` | CA Opaque | All | Web Server | No | +| `testCAOpaque_DefaultScopes_UserAgentFlow` | CA Opaque | Default | User Agent | No | +| `testCAOpaque_SubsetScopes_UserAgentFlow` | CA Opaque | Subset | User Agent | No | +| `testCAOpaque_AllScopes_UserAgentFlow` | CA Opaque | All | User Agent | No | + +### ECALoginTests (8 tests) + +Tests for External Client App (ECA) configurations using web server flow with hybrid auth. Includes negative testing scenarios for invalid client ID and invalid scopes. + +| Test Name | App Config | Scopes | Description | +|-----------|------------|--------|-------------| +| `testECAOpaque_DefaultScopes` | ECA Opaque | Default | Standard login with default scopes | +| `testECAOpaque_SubsetScopes` | ECA Opaque | Subset | Standard login with subset scopes | +| `testECAOpaque_AllScopes` | ECA Opaque | All | Standard login with all scopes | +| `testECAJwt_DefaultScopes` | ECA JWT | Default | Standard login with default scopes | +| `testECAJwt_SubsetScopes` | ECA JWT | Subset | Standard login with subset scopes | +| `testECAJwt_AllScopes` | ECA JWT | All | Standard login with all scopes | +| `testDynamicConfigurationWithInvalidClientId` | Invalid (dynamic) | Default | Negative test: invalid client ID in dynamic config | +| `testDynamicConfigurationWithInvalidScope` | ECA JWT (dynamic) | Invalid | Negative test: invalid scope in dynamic config | ### BeaconLoginTests (6 tests) @@ -108,9 +118,9 @@ Tests for verifying that user sessions persist across app restarts. Includes CA, ## Migration Tests -### RefreshTokenMigrationTests (9 tests) +### RefreshTokenMigrationTests (11 tests) -Tests for migrating refresh tokens between different app configurations without re-authentication. +Tests for migrating refresh tokens between different app configurations without re-authentication. Tests can optionally specify the OAuth flow type (web server vs user agent) and hybrid flow setting to use during migration. | Test Name | Original App | Migration App | Scope Change | Multi-User | |-----------|--------------|---------------|--------------|------------| @@ -119,6 +129,8 @@ Tests for migrating refresh tokens between different app configurations without | `testMigrateBeacon_AddMoreScopes` | Beacon JWT (subset) | Beacon JWT (all) | Yes | No | | `testMigrateCAToBeacon` | CA Opaque | Beacon Opaque | No | No | | `testMigrateBeaconToCA` | Beacon Opaque | CA Opaque | No | No | +| `testMigrateCAUserAgentToECAWebServer` | CA Opaque (user agent) | ECA Opaque (web server) | No | No | +| `testMigrateCAUserAgentToBeaconWebServer` | CA Opaque (user agent) | Beacon Opaque (web server) | No | No | | `testMigrateCAToECA` | CA Opaque → ECA Opaque → CA Opaque | No | No | | `testMigrateCAToBeaconAndBack` | CA Opaque → Beacon Opaque → CA Opaque | No | No | | `testMigrateBeaconOpaqueToJWTAndBack` | Beacon Opaque → Beacon JWT → Beacon Opaque | No | No | @@ -140,21 +152,23 @@ Tests for verifying that migrated refresh tokens persist across app restarts. Co ## Multi-User Tests -### MultiUserLoginTests (9 tests) +### MultiUserLoginTests (11 tests) -Tests for login scenarios with two users using various configurations, including token revocation scenarios. +Tests for login scenarios with two users using various configurations, including token revocation and user logout scenarios. -| Test Name | User 1 Config | User 2 Config | Same App | Same Scopes | Beacon | Token Revocation | -|-----------|---------------|---------------|----------|-------------|--------|------------------| -| `testBothStatic_SameApp_SameScopes` | Static (Opaque) | Static (Opaque) | Yes | Yes | No | No | -| `testBothStatic_DifferentApps` | Static (Opaque) | Static (JWT) | No | Yes | No | No | -| `testBothStatic_SameApp_DifferentScopes` | Static (Opaque, subset) | Static (Opaque, default) | Yes | No | No | No | -| `testFirstStatic_SecondDynamic_DifferentApps` | Static (Opaque) | Dynamic (JWT) | No | Yes | No | No | -| `testFirstDynamic_SecondStatic_DifferentApps` | Dynamic (JWT) | Static (Opaque) | No | Yes | No | No | -| `testBothDynamic_DifferentApps` | Dynamic (Opaque) | Dynamic (JWT) | No | Yes | No | No | -| `testBeaconAndNonBeacon_MultiUser` | Beacon (Opaque) | CA (Opaque) | No | Yes | Yes | No | -| `testRevokeUserWithDynamicConfig_OtherUserUnaffected` | ECA (Opaque) static | ECA (JWT) dynamic | No | Yes | No | Yes (User B) | -| `testDifferentAppTypes_RevokeCaUser_EcaUserUnaffected` | CA (Opaque) | ECA (Opaque) | No | Yes | No | Yes (User A) | +| Test Name | User 1 Config | User 2 Config | Same App | Same Scopes | Beacon | Action | +|-----------|---------------|---------------|----------|-------------|--------|--------| +| `testBothStatic_SameApp_SameScopes` | Static (Opaque) | Static (Opaque) | Yes | Yes | No | None | +| `testBothStatic_DifferentApps` | Static (Opaque) | Static (JWT) | No | Yes | No | None | +| `testBothStatic_SameApp_DifferentScopes` | Static (Opaque, subset) | Static (Opaque, default) | Yes | No | No | None | +| `testFirstStatic_SecondDynamic_DifferentApps` | Static (Opaque) | Dynamic (JWT) | No | Yes | No | None | +| `testFirstDynamic_SecondStatic_DifferentApps` | Dynamic (JWT) | Static (Opaque) | No | Yes | No | None | +| `testBothDynamic_DifferentApps` | Dynamic (Opaque) | Dynamic (JWT) | No | Yes | No | None | +| `testBeaconAndNonBeacon_MultiUser` | Beacon (Opaque) | CA (Opaque) | No | Yes | Yes | None | +| `testRevokeAccessForUserWithDynamicConfig_OtherUserUnaffected` | ECA (Opaque) static | ECA (JWT) dynamic | No | Yes | No | Revoke User B | +| `testDifferentAppTypes_RevokeAccessForCaUser_EcaUserUnaffected` | CA (Opaque) | ECA (Opaque) | No | Yes | No | Revoke User A | +| `testLogoutUserWithDynamicConfig_OtherUserUnaffected` | ECA (Opaque) static | ECA (JWT) dynamic | No | Yes | No | Logout User B | +| `testDifferentAppTypes_LogoutCaUser_EcaUserUnaffected` | CA (Opaque) | ECA (Opaque) | No | Yes | No | Logout User A | --- @@ -165,6 +179,7 @@ Tests for login scenarios with two users using various configurations, including | **Default** | No scopes requested (all scopes defined in server config should be granted) | | **Subset** | Explicitly requests all scopes except for sfap_api | | **All** | Explicitly requests all scopes | +| **Invalid** | Requests "invalid_scope" for negative testing scenarios | ## App Configuration Types @@ -191,6 +206,7 @@ Tests for login scenarios with two users using various configurations, including | `beaconJwt` | Beacon | JWT | `api content id lightning refresh_token sfap_api web` | | `caOpaque` | CA | Opaque | `api content id lightning refresh_token sfap_api visualforce web` | | `caJwt` | CA | JWT | `api content id lightning refresh_token sfap_api visualforce web` | +| `invalid` | Invalid | N/A | `invalid_scope` (for negative testing) | ### Token Formats @@ -199,6 +215,20 @@ Tests for login scenarios with two users using various configurations, including | **Opaque** | Opaque access tokens | | **JWT** | JSON Web Token based access tokens | +### OAuth Flow Types + +| Flow Type | Description | +|-----------|-------------| +| **Web Server Flow** | OAuth 2.0 web server flow (authorization code flow) - default | +| **User Agent Flow** | OAuth 2.0 user agent flow (implicit flow) | + +### Hybrid Flow + +| Setting | Description | +|---------|-------------| +| **Hybrid** | Authentication includes front-door session cookies (SIDs) for Lightning, Visualforce, and Content domains | +| **Non-Hybrid** | Authentication without front-door session cookies | + ## Login Hosts The test suite supports testing against different Salesforce org configurations with different authentication mechanisms. The login host configuration is specified in `ui_test_config.json` under the `loginHosts` array.