diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/xcshareddata/xcschemes/SalesforceSDKCore.xcscheme b/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/xcshareddata/xcschemes/SalesforceSDKCore.xcscheme index 48cf1fad11..544bf72561 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/xcshareddata/xcschemes/SalesforceSDKCore.xcscheme +++ b/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/xcshareddata/xcschemes/SalesforceSDKCore.xcscheme @@ -26,7 +26,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> Void let initiallyExpanded: Bool @State private var isExpanded: Bool = false + @State private var showImportAlert: Bool = false + @State private var importJSONText: String = "" public init( title: String, @@ -63,21 +72,32 @@ public struct BootConfigEditor: View { public var body: some View { VStack(alignment: .leading, spacing: 12) { - Button(action: { - withAnimation { - isExpanded.toggle() + HStack { + Button(action: { + withAnimation { + isExpanded.toggle() + } + }) { + HStack { + Text(title) + .font(.headline) + .foregroundColor(.primary) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .foregroundColor(.secondary) + } } - }) { - HStack { - Text(title) - .font(.headline) - .foregroundColor(.primary) - Spacer() - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .foregroundColor(.secondary) + Button(action: { + importJSONText = "" + showImportAlert = true + }) { + Image(systemName: "square.and.arrow.down") + .font(.subheadline) + .foregroundColor(.blue) } - .padding(.horizontal) + .accessibilityIdentifier("importConfigButton") } + .padding(.horizontal) if isExpanded { VStack(alignment: .leading, spacing: 8) { @@ -130,6 +150,34 @@ public struct BootConfigEditor: View { .onAppear { isExpanded = initiallyExpanded } + .alert("Import Configuration", isPresented: $showImportAlert) { + TextField("Paste JSON here", text: $importJSONText) + Button("Import") { + importConfigFromJSON() + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Paste JSON with remoteAccessConsumerKey, oauthRedirectURI, and scopes") + } + } + + // MARK: - Helper Methods + + private func importConfigFromJSON() { + guard let jsonData = importJSONText.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + return + } + + if let key = json[BootConfigJSONKeys.consumerKey] as? String { + consumerKey = key + } + if let uri = json[BootConfigJSONKeys.redirectUri] as? String { + callbackUrl = uri + } + if let scopesValue = json[BootConfigJSONKeys.scopes] as? String { + scopes = scopesValue + } } } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.h index 032b65eca8..4860f8a25e 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.h @@ -54,6 +54,9 @@ NS_SWIFT_NAME(SalesforceLoginViewControllerDelegate) - (void)loginViewControllerDidReload:(nonnull SFLoginViewController *)loginViewController; +- (void)loginViewControllerDidChangeLoginOptions:(nonnull SFLoginViewController *)loginViewController; + + @end /** The Salesforce login screen view. diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m index 0eb2d8f828..5310c73a05 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m @@ -317,8 +317,8 @@ - (UIBarButtonItem *)createSettingsButton { handler:^(__kindof UIAction* _Nonnull action) { UIViewController *configPicker = [BootConfigPickerViewController makeViewControllerOnConfigurationCompleted:^{ [self dismissViewControllerAnimated:YES completion:^{ - if ([self.delegate respondsToSelector:@selector(loginViewControllerDidReload:)]) { - [self.delegate loginViewControllerDidReload:self]; + if ([self.delegate respondsToSelector:@selector(loginViewControllerDidChangeLoginOptions:)]) { + [self.delegate loginViewControllerDidChangeLoginOptions:self]; } }]; }]; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator+Internal.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator+Internal.h index 32e8a24de1..99ecd17180 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator+Internal.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator+Internal.h @@ -81,7 +81,7 @@ NS_ASSUME_NONNULL_BEGIN * Returns the scope query parameter string for OAuth requests. * @return A properly formatted scope parameter string, or empty string if no scopes provided. */ -- (NSString *)scopeQueryParamString; +- (NSString *)scopeQueryParamString:(NSArray*)scopes; /** Migrates the refresh token for a user to a new app configuration. diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m index a5e727aee4..3be166cb03 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m @@ -821,7 +821,7 @@ - (NSString *)approvalURLForEndpoint:(NSString *)authorizeEndpoint } // OAuth scopes - NSString *scopeString = [self scopeQueryParamString]; + NSString *scopeString = [self scopeQueryParamString:credentials.scopes]; if (scopeString.length > 0) { [approvalUrlString appendString:scopeString]; } @@ -842,9 +842,9 @@ -(void) clearFrontDoorBridgeLoginOverride { self.frontdoorBridgeLoginOverride = nil; } -- (NSString *)scopeQueryParamString { - if (self.scopes.count > 0) { - NSString *scopeStr = [SFScopeParser computeScopeParameterWithURLEncodingWithScopes:self.scopes]; +- (NSString *)scopeQueryParamString:(NSArray*)scopes { + if (scopes.count > 0) { + NSString *scopeStr = [SFScopeParser computeScopeParameterWithURLEncodingWithScopes:[NSSet setWithArray:scopes]]; return [NSString stringWithFormat:@"&%@=%@", kSFOAuthScope, scopeStr]; } else { return @""; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthSession.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthSession.m index 7ea7bf0a9c..36830ef613 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthSession.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthSession.m @@ -73,6 +73,7 @@ - (SFOAuthCredentials *)newClientCredentials { creds.clientId = self.oauthRequest.oauthClientId; creds.redirectUri = self.oauthRequest.oauthCompletionUrl; creds.domain = self.oauthRequest.loginHost; + creds.scopes = [self.oauthRequest.scopes allObjects]; creds.accessToken = nil; return creds; } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager+Internal.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager+Internal.h index 05c7928468..ec4ccf62a3 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager+Internal.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager+Internal.h @@ -218,6 +218,9 @@ Set this block to handle presentation of the Authentication View Controller. - (void)restartAuthenticationForViewController:(SFLoginViewController *)loginViewController; +- (void)restartAuthenticationForViewController:(SFLoginViewController *)loginViewController recreateAuthRequest:(BOOL)recreateAuthRequest; + + @end NS_ASSUME_NONNULL_END diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m index 5318114449..ecb25f75ef 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m @@ -579,6 +579,7 @@ -(SFSDKAuthRequest *)migrateRefreshAuthRequest:(SFSDKAppConfig *)newAppConfig { request.additionalOAuthParameterKeys = self.additionalOAuthParameterKeys; request.oauthClientId = newAppConfig.remoteAccessConsumerKey; request.oauthCompletionUrl = newAppConfig.oauthRedirectURI; + request.scopes = newAppConfig.oauthScopes; request.scene = [[SFSDKWindowManager sharedManager] defaultScene]; return request; } @@ -1119,9 +1120,26 @@ - (void)loginViewControllerDidReload:(SFLoginViewController *)loginViewControlle [self restartAuthenticationForViewController:loginViewController]; } +- (void)loginViewControllerDidChangeLoginOptions:(SFLoginViewController *)loginViewController { + [self restartAuthenticationForViewController:loginViewController recreateAuthRequest:YES]; +} + - (void)restartAuthenticationForViewController:(SFLoginViewController *)loginViewController { + [self restartAuthenticationForViewController:loginViewController recreateAuthRequest:NO]; +} + +- (void)restartAuthenticationForViewController:(SFLoginViewController *)loginViewController recreateAuthRequest:(BOOL)recreateAuthRequest { NSString *sceneId = loginViewController.view.window.windowScene.session.persistentIdentifier; - [self restartAuthentication:self.authSessions[sceneId]]; + + SFSDKAuthSession* session = self.authSessions[sceneId]; + + // Recreate the oauth request + // Otherwise changes to consumer key / callback url or scopes will not get picked up + if (recreateAuthRequest) { + session.oauthRequest = [self defaultAuthRequestWithLoginHost:session.oauthRequest.loginHost]; + } + + [self restartAuthentication:session]; } #pragma mark - SFSDKLoginHostDelegate diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceOAuthUnitTests.m b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceOAuthUnitTests.m index 2fbb3e81c3..90551a25f5 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceOAuthUnitTests.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceOAuthUnitTests.m @@ -464,10 +464,10 @@ - (void)testDefaultTokenEncryption { - (void)testScopeQueryParamStringEmptyScopes { // Given SFOAuthCoordinator *coordinator = [[SFOAuthCoordinator alloc] init]; - coordinator.scopes = [NSSet set]; + NSArray *scopes = [NSArray array]; // When - NSString *result = [coordinator scopeQueryParamString]; + NSString *result = [coordinator scopeQueryParamString:scopes]; // Then XCTAssertEqualObjects(result, @"", @"Empty scopes should return empty string"); @@ -476,10 +476,10 @@ - (void)testScopeQueryParamStringEmptyScopes { - (void)testScopeQueryParamStringNilScopes { // Given SFOAuthCoordinator *coordinator = [[SFOAuthCoordinator alloc] init]; - coordinator.scopes = nil; + NSArray *scopes = nil; // When - NSString *result = [coordinator scopeQueryParamString]; + NSString *result = [coordinator scopeQueryParamString:scopes]; // Then XCTAssertEqualObjects(result, @"", @"Nil scopes should return empty string"); @@ -488,10 +488,10 @@ - (void)testScopeQueryParamStringNilScopes { - (void)testScopeQueryParamStringSingleScope { // Given SFOAuthCoordinator *coordinator = [[SFOAuthCoordinator alloc] init]; - coordinator.scopes = [NSSet setWithObject:@"web"]; + NSArray *scopes = @[@"web"]; // When - NSString *result = [coordinator scopeQueryParamString]; + NSString *result = [coordinator scopeQueryParamString:scopes]; // Then // Should include refresh_token and the provided scope, URL encoded @@ -501,10 +501,10 @@ - (void)testScopeQueryParamStringSingleScope { - (void)testScopeQueryParamStringMultipleScopes { // Given SFOAuthCoordinator *coordinator = [[SFOAuthCoordinator alloc] init]; - coordinator.scopes = [NSSet setWithObjects:@"web", @"api", @"id", nil]; + NSArray *scopes = @[@"web", @"api", @"id"]; // When - NSString *result = [coordinator scopeQueryParamString]; + NSString *result = [coordinator scopeQueryParamString:scopes]; // Then // Should include refresh_token and all provided scopes, sorted and URL encoded @@ -514,10 +514,10 @@ - (void)testScopeQueryParamStringMultipleScopes { - (void)testScopeQueryParamStringWithRefreshTokenAlreadyPresent { // Given SFOAuthCoordinator *coordinator = [[SFOAuthCoordinator alloc] init]; - coordinator.scopes = [NSSet setWithObjects:@"web", @"api", @"refresh_token", nil]; - + NSArray *scopes = @[@"web", @"api", @"refresh_token"]; + // When - NSString *result = [coordinator scopeQueryParamString]; + NSString *result = [coordinator scopeQueryParamString:scopes]; // Then // Should still work correctly even if refresh_token is already present diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/project.pbxproj b/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/project.pbxproj index 4baf5fd771..64e09cae45 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/project.pbxproj +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/project.pbxproj @@ -26,10 +26,20 @@ AUTH037 /* UserCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTH038 /* UserCredentialsView.swift */; }; AUTH040 /* RestApiTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTH041 /* RestApiTestView.swift */; }; AUTH042 /* OAuthConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTH043 /* OAuthConfigurationView.swift */; }; - AUTH049 /* JwtAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTH050 /* JwtAccessView.swift */; }; + AUTH049 /* JwtTokenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTH050 /* JwtTokenView.swift */; }; AUTH051 /* InfoRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUTH052 /* InfoRowView.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 4F8E4AF72ED13CE800DA7B7A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AUTH027 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AUTH023; + remoteInfo = AuthFlowTester; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 4F95A89E2EA806E700C98D18 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -46,6 +56,7 @@ /* Begin PBXFileReference section */ 4F5945B72ED51C00003C5BDE /* InfoSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoSectionView.swift; sourceTree = ""; }; + 4F8E4AF12ED13CE800DA7B7A /* AuthFlowTesterUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AuthFlowTesterUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 4F95A8962EA801DC00C98D18 /* SalesforceAnalytics.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SalesforceAnalytics.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4F95A8982EA801DC00C98D18 /* SalesforceSDKCommon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SalesforceSDKCommon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4F95A89A2EA801DC00C98D18 /* SalesforceSDKCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SalesforceSDKCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -64,11 +75,37 @@ AUTH038 /* UserCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCredentialsView.swift; sourceTree = ""; }; AUTH041 /* RestApiTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestApiTestView.swift; sourceTree = ""; }; AUTH043 /* OAuthConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthConfigurationView.swift; sourceTree = ""; }; - AUTH050 /* JwtAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JwtAccessView.swift; sourceTree = ""; }; + AUTH050 /* JwtTokenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JwtTokenView.swift; sourceTree = ""; }; AUTH052 /* InfoRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoRowView.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4F5946272ED670C7003C5BDE /* Exceptions for "AuthFlowTesterUITests" folder in "AuthFlowTesterUITests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + test_config.json.sample, + ); + target = 4F8E4AF02ED13CE800DA7B7A /* AuthFlowTesterUITests */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 4F8E4AF22ED13CE800DA7B7A /* AuthFlowTesterUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4F5946272ED670C7003C5BDE /* Exceptions for "AuthFlowTesterUITests" folder in "AuthFlowTesterUITests" target */, + ); + path = AuthFlowTesterUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ + 4F8E4AEE2ED13CE800DA7B7A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + files = ( + ); + }; AUTH018 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( @@ -106,6 +143,7 @@ isa = PBXGroup; children = ( AUTH020 /* AuthFlowTester */, + 4F8E4AF22ED13CE800DA7B7A /* AuthFlowTesterUITests */, 4F95A8952EA801DC00C98D18 /* Frameworks */, AUTH021 /* Products */, ); @@ -127,6 +165,7 @@ isa = PBXGroup; children = ( AUTH017 /* AuthFlowTester.app */, + 4F8E4AF12ED13CE800DA7B7A /* AuthFlowTesterUITests.xctest */, ); name = Products; sourceTree = ""; @@ -156,7 +195,7 @@ AUTH038 /* UserCredentialsView.swift */, AUTH041 /* RestApiTestView.swift */, AUTH043 /* OAuthConfigurationView.swift */, - AUTH050 /* JwtAccessView.swift */, + AUTH050 /* JwtTokenView.swift */, AUTH052 /* InfoRowView.swift */, ); path = Views; @@ -174,6 +213,27 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 4F8E4AF02ED13CE800DA7B7A /* AuthFlowTesterUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4F8E4AFB2ED13CE800DA7B7A /* Build configuration list for PBXNativeTarget "AuthFlowTesterUITests" */; + buildPhases = ( + 4F8E4AED2ED13CE800DA7B7A /* Sources */, + 4F8E4AEE2ED13CE800DA7B7A /* Frameworks */, + 4F8E4AEF2ED13CE800DA7B7A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 4F8E4AF82ED13CE800DA7B7A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 4F8E4AF22ED13CE800DA7B7A /* AuthFlowTesterUITests */, + ); + name = AuthFlowTesterUITests; + productName = AuthFlowTesterUITests; + productReference = 4F8E4AF12ED13CE800DA7B7A /* AuthFlowTesterUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; AUTH023 /* AuthFlowTester */ = { isa = PBXNativeTarget; buildConfigurationList = AUTH024 /* Build configuration list for PBXNativeTarget "AuthFlowTester" */; @@ -197,9 +257,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2600; + LastSwiftUpdateCheck = 2610; LastUpgradeCheck = 2600; TargetAttributes = { + 4F8E4AF02ED13CE800DA7B7A = { + CreatedOnToolsVersion = 26.1.1; + TestTargetID = AUTH023; + }; AUTH023 = { CreatedOnToolsVersion = 15.0; }; @@ -219,11 +283,17 @@ projectRoot = ""; targets = ( AUTH023 /* AuthFlowTester */, + 4F8E4AF02ED13CE800DA7B7A /* AuthFlowTesterUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 4F8E4AEF2ED13CE800DA7B7A /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; AUTH026 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( @@ -236,6 +306,11 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 4F8E4AED2ED13CE800DA7B7A /* Sources */ = { + isa = PBXSourcesBuildPhase; + files = ( + ); + }; AUTH025 /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( @@ -247,14 +322,62 @@ 4FE006B52EBEB94700CFD66F /* InitialViewController.swift in Sources */, AUTH042 /* OAuthConfigurationView.swift in Sources */, 4FEBAF292EA9B91500D4880A /* RevokeView.swift in Sources */, - AUTH049 /* JwtAccessView.swift in Sources */, + AUTH049 /* JwtTokenView.swift in Sources */, AUTH051 /* InfoRowView.swift in Sources */, 4F5945B82ED51C00003C5BDE /* InfoSectionView.swift in Sources */, ); }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 4F8E4AF82ED13CE800DA7B7A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AUTH023 /* AuthFlowTester */; + targetProxy = 4F8E4AF72ED13CE800DA7B7A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 4F8E4AF92ED13CE800DA7B7A /* Debug configuration for PBXNativeTarget "AuthFlowTesterUITests" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.salesforce.mobilesdk.AuthFlowTesterUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = AuthFlowTester; + }; + name = Debug; + }; + 4F8E4AFA2ED13CE800DA7B7A /* Release configuration for PBXNativeTarget "AuthFlowTesterUITests" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.salesforce.mobilesdk.AuthFlowTesterUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = AuthFlowTester; + }; + name = Release; + }; AUTH029 /* Debug configuration for PBXProject "AuthFlowTester" */ = { isa = XCBuildConfiguration; buildSettings = { @@ -439,6 +562,14 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 4F8E4AFB2ED13CE800DA7B7A /* Build configuration list for PBXNativeTarget "AuthFlowTesterUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4F8E4AF92ED13CE800DA7B7A /* Debug configuration for PBXNativeTarget "AuthFlowTesterUITests" */, + 4F8E4AFA2ED13CE800DA7B7A /* Release configuration for PBXNativeTarget "AuthFlowTesterUITests" */, + ); + defaultConfigurationName = Release; + }; AUTH024 /* Build configuration list for PBXNativeTarget "AuthFlowTester" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/xcshareddata/xcschemes/AuthFlowTester.xcscheme b/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/xcshareddata/xcschemes/AuthFlowTester.xcscheme index a8d8db2bbe..12ade262e4 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/xcshareddata/xcschemes/AuthFlowTester.xcscheme +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester.xcodeproj/xcshareddata/xcschemes/AuthFlowTester.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -26,18 +26,23 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + + diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester/ViewControllers/SessionDetailViewController.swift b/native/SampleApps/AuthFlowTester/AuthFlowTester/ViewControllers/SessionDetailViewController.swift index 6067c9959f..ff549bd785 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTester/ViewControllers/SessionDetailViewController.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester/ViewControllers/SessionDetailViewController.swift @@ -67,7 +67,7 @@ struct SessionDetailView: View { credentials.tokenFormat?.lowercased() == "jwt", let accessToken = credentials.accessToken, let jwtToken = try? JwtAccessToken(jwt: accessToken) { - JwtAccessView(jwtToken: jwtToken, isExpanded: $isJwtDetailsExpanded) + JwtTokenView(jwtToken: jwtToken, isExpanded: $isJwtDetailsExpanded) .id(refreshTrigger) } diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/InfoRowView.swift b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/InfoRowView.swift index 2b3e36e610..ddf8ba82fd 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/InfoRowView.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/InfoRowView.swift @@ -65,6 +65,8 @@ struct InfoRowView: View { } } .padding(.vertical, 4) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("\(label)_row") } // MARK: - Computed Properties diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/JwtAccessView.swift b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/JwtAccessView.swift index dfc7710dbe..0519ecba6e 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/JwtAccessView.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/JwtAccessView.swift @@ -1,154 +1 @@ -/* - JwtAccessView.swift - AuthFlowTester - - Copyright (c) 2025-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 SwiftUI -import SalesforceSDKCore - -struct JwtAccessView: View { - let jwtToken: JwtAccessToken - @Binding var isExpanded: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Button(action: { - withAnimation { - isExpanded.toggle() - } - }) { - HStack { - Text("JWT Access Token Details") - .font(.headline) - .foregroundColor(.primary) - Spacer() - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundColor(.secondary) - } - } - - if isExpanded { - // JWT Header - Text("Header:") - .font(.subheadline) - .fontWeight(.semibold) - .padding(.top, 4) - - JwtHeaderView(token: jwtToken) - - // JWT Payload - Text("Payload:") - .font(.subheadline) - .fontWeight(.semibold) - .padding(.top, 8) - - JwtPayloadView(token: jwtToken) - } - } - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(8) - } -} - -// MARK: - JWT Header View - -struct JwtHeaderView: View { - let token: JwtAccessToken - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - let header = token.header - - if let algorithm = header.algorithm { - InfoRowView(label: "Algorithm (alg):", value: algorithm) - } - if let type = header.type { - InfoRowView(label: "Type (typ):", value: type) - } - if let keyId = header.keyId { - InfoRowView(label: "Key ID (kid):", value: keyId) - } - if let tokenType = header.tokenType { - InfoRowView(label: "Token Type (tty):", value: tokenType) - } - if let tenantKey = header.tenantKey { - InfoRowView(label: "Tenant Key (tnk):", value: tenantKey) - } - if let version = header.version { - InfoRowView(label: "Version (ver):", value: version) - } - } - .padding(8) - .background(Color(.systemGray6)) - .cornerRadius(4) - } -} - -// MARK: - JWT Payload View - -struct JwtPayloadView: View { - let token: JwtAccessToken - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - let payload = token.payload - - if let audience = payload.audience { - InfoRowView(label: "Audience (aud):", value: audience.joined(separator: ", ")) - } - - if let expirationDate = token.expirationDate() { - InfoRowView(label: "Expiration Date (exp):", value: formatDate(expirationDate)) - } - - if let issuer = payload.issuer { - InfoRowView(label: "Issuer (iss):", value: issuer) - } - if let subject = payload.subject { - InfoRowView(label: "Subject (sub):", value: subject) - } - if let scopes = payload.scopes { - InfoRowView(label: "Scopes (scp):", value: scopes) - } - if let clientId = payload.clientId { - InfoRowView(label: "Client ID (client_id):", value: clientId, isSensitive: true) - } - } - .padding(8) - .background(Color(.systemGray6)) - .cornerRadius(4) - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .medium - return formatter.string(from: date) - } -} - - + \ No newline at end of file diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/JwtTokenView.swift b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/JwtTokenView.swift new file mode 100644 index 0000000000..eb603cb18c --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/JwtTokenView.swift @@ -0,0 +1,266 @@ +/* + JwtTokenView.swift + AuthFlowTester + + Copyright (c) 2025-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 SwiftUI +import SalesforceSDKCore + +// MARK: - Labels Constants +public struct JwtTokenLabels { + // Section titles + public static let header = "Header" + public static let payload = "Payload" + + // Header fields + public static let algorithm = "Algorithm (alg)" + public static let type = "Type (typ)" + public static let keyId = "Key ID (kid)" + public static let tokenType = "Token Type (tty)" + public static let tenantKey = "Tenant Key (tnk)" + public static let version = "Version (ver)" + + // Payload fields + public static let audience = "Audience (aud)" + public static let expirationDate = "Expiration Date (exp)" + public static let issuer = "Issuer (iss)" + public static let subject = "Subject (sub)" + public static let scopes = "Scopes (scp)" + public static let clientId = "Client ID (client_id)" +} + +struct JwtTokenView: View { + let jwtToken: JwtAccessToken + @Binding var isExpanded: Bool + + // Export alert state + @State private var showExportAlert = false + @State private var exportedJSON = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button(action: { + withAnimation { + isExpanded.toggle() + } + }) { + HStack { + Text("JWT Access Token Details") + .font(.headline) + .foregroundColor(.primary) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundColor(.secondary) + } + } + Button(action: { + exportedJSON = generateJwtJSON() + showExportAlert = true + }) { + Image(systemName: "square.and.arrow.up") + .font(.subheadline) + .foregroundColor(.blue) + } + .accessibilityIdentifier("exportJwtTokenButton") + } + + if isExpanded { + // JWT Header + Text("\(JwtTokenLabels.header):") + .font(.subheadline) + .fontWeight(.semibold) + .padding(.top, 4) + + JwtHeaderView(token: jwtToken) + + // JWT Payload + Text("\(JwtTokenLabels.payload):") + .font(.subheadline) + .fontWeight(.semibold) + .padding(.top, 8) + + JwtPayloadView(token: jwtToken) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) + .alert("JWT Token JSON", isPresented: $showExportAlert) { + Button("Copy to Clipboard") { + UIPasteboard.general.string = exportedJSON + } + Button("OK", role: .cancel) { } + } message: { + Text(exportedJSON) + } + } + + // MARK: - Helper Methods + + private func generateJwtJSON() -> String { + var result: [String: [String: String]] = [:] + + // Header section + var headerDict: [String: String] = [:] + let header = jwtToken.header + if let algorithm = header.algorithm { + headerDict[JwtTokenLabels.algorithm] = algorithm + } + if let type = header.type { + headerDict[JwtTokenLabels.type] = type + } + if let keyId = header.keyId { + headerDict[JwtTokenLabels.keyId] = keyId + } + if let tokenType = header.tokenType { + headerDict[JwtTokenLabels.tokenType] = tokenType + } + if let tenantKey = header.tenantKey { + headerDict[JwtTokenLabels.tenantKey] = tenantKey + } + if let version = header.version { + headerDict[JwtTokenLabels.version] = version + } + result[JwtTokenLabels.header] = headerDict + + // Payload section + var payloadDict: [String: String] = [:] + let payload = jwtToken.payload + if let audience = payload.audience { + payloadDict[JwtTokenLabels.audience] = audience.joined(separator: ", ") + } + if let expirationDate = jwtToken.expirationDate() { + payloadDict[JwtTokenLabels.expirationDate] = formatDate(expirationDate) + } + if let issuer = payload.issuer { + payloadDict[JwtTokenLabels.issuer] = issuer + } + if let subject = payload.subject { + payloadDict[JwtTokenLabels.subject] = subject + } + if let scopes = payload.scopes { + payloadDict[JwtTokenLabels.scopes] = scopes + } + if let clientId = payload.clientId { + payloadDict[JwtTokenLabels.clientId] = clientId + } + result[JwtTokenLabels.payload] = payloadDict + + guard let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted]), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return "{}" + } + + return jsonString + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter.string(from: date) + } +} + +// MARK: - JWT Header View + +struct JwtHeaderView: View { + let token: JwtAccessToken + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + let header = token.header + + if let algorithm = header.algorithm { + InfoRowView(label: "\(JwtTokenLabels.algorithm):", value: algorithm) + } + if let type = header.type { + InfoRowView(label: "\(JwtTokenLabels.type):", value: type) + } + if let keyId = header.keyId { + InfoRowView(label: "\(JwtTokenLabels.keyId):", value: keyId) + } + if let tokenType = header.tokenType { + InfoRowView(label: "\(JwtTokenLabels.tokenType):", value: tokenType) + } + if let tenantKey = header.tenantKey { + InfoRowView(label: "\(JwtTokenLabels.tenantKey):", value: tenantKey) + } + if let version = header.version { + InfoRowView(label: "\(JwtTokenLabels.version):", value: version) + } + } + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(4) + } +} + +// MARK: - JWT Payload View + +struct JwtPayloadView: View { + let token: JwtAccessToken + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + let payload = token.payload + + if let audience = payload.audience { + InfoRowView(label: "\(JwtTokenLabels.audience):", value: audience.joined(separator: ", ")) + } + + if let expirationDate = token.expirationDate() { + InfoRowView(label: "\(JwtTokenLabels.expirationDate):", value: formatDate(expirationDate)) + } + + if let issuer = payload.issuer { + InfoRowView(label: "\(JwtTokenLabels.issuer):", value: issuer) + } + if let subject = payload.subject { + InfoRowView(label: "\(JwtTokenLabels.subject):", value: subject) + } + if let scopes = payload.scopes { + InfoRowView(label: "\(JwtTokenLabels.scopes):", value: scopes) + } + if let clientId = payload.clientId { + InfoRowView(label: "\(JwtTokenLabels.clientId):", value: clientId, isSensitive: true) + } + } + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(4) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter.string(from: date) + } +} + + diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/OAuthConfigurationView.swift b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/OAuthConfigurationView.swift index cb112b6b95..b34bc54470 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/OAuthConfigurationView.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/OAuthConfigurationView.swift @@ -28,48 +28,95 @@ import SwiftUI import SalesforceSDKCore +// MARK: - Labels Constants +public struct OAuthConfigLabels { + public static let consumerKey = "Configured Consumer Key" + public static let callbackUrl = "Configured Callback URL" + public static let scopes = "Configured Scopes" +} + struct OAuthConfigurationView: View { @Binding var isExpanded: Bool + // Export alert state + @State private var showExportAlert = false + @State private var exportedJSON = "" + var body: some View { VStack(alignment: .leading, spacing: 8) { - Button(action: { - withAnimation { - isExpanded.toggle() + HStack { + Button(action: { + withAnimation { + isExpanded.toggle() + } + }) { + HStack { + Text("OAuth Configuration") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundColor(.secondary) + } } - }) { - HStack { - Text("OAuth Configuration") - .font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - Spacer() - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundColor(.secondary) + Button(action: { + exportedJSON = generateConfigJSON() + showExportAlert = true + }) { + Image(systemName: "square.and.arrow.up") + .font(.subheadline) + .foregroundColor(.blue) } + .accessibilityIdentifier("exportOAuthConfigButton") } if isExpanded { InfoRowView(label: configuredConsumerKeyLabel, value: configuredConsumerKey, isSensitive: true) - InfoRowView(label: "Configured Callback URL:", + InfoRowView(label: "\(OAuthConfigLabels.callbackUrl):", value: configuredCallbackUrl) - InfoRowView(label: "Configured Scopes:", + InfoRowView(label: "\(OAuthConfigLabels.scopes):", value: configuredScopes) } } .padding() .background(Color(.secondarySystemBackground)) .cornerRadius(8) + .alert("OAuth Configuration JSON", isPresented: $showExportAlert) { + Button("Copy to Clipboard") { + UIPasteboard.general.string = exportedJSON + } + Button("OK", role: .cancel) { } + } message: { + Text(exportedJSON) + } + } + + // MARK: - Helper Methods + + private func generateConfigJSON() -> String { + let result: [String: String] = [ + OAuthConfigLabels.consumerKey: configuredConsumerKey, + OAuthConfigLabels.callbackUrl: configuredCallbackUrl, + OAuthConfigLabels.scopes: configuredScopes + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted]), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return "{}" + } + + return jsonString } // MARK: - Computed Properties private var configuredConsumerKeyLabel: String { - let label = "Configured Consumer Key:" + let label = "\(OAuthConfigLabels.consumerKey):" if let defaultKey = SalesforceManager.shared.bootConfig?.remoteAccessConsumerKey, configuredConsumerKey == defaultKey { return "\(label) (default)" @@ -78,16 +125,16 @@ struct OAuthConfigurationView: View { } private var configuredConsumerKey: String { - return UserAccountManager.shared.oauthClientID ?? "" + return UserAccountManager.shared.oauthClientID } private var configuredCallbackUrl: String { - return UserAccountManager.shared.oauthCompletionURL ?? "" + return UserAccountManager.shared.oauthCompletionURL } private var configuredScopes: String { let scopes = UserAccountManager.shared.scopes - return scopes.isEmpty ? "(none)" : scopes.sorted().joined(separator: ", ") + return scopes.isEmpty ? "(none)" : scopes.sorted().joined(separator: " ") } } diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/UserCredentialsView.swift b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/UserCredentialsView.swift index 42cf1abdbd..1443cc7819 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/UserCredentialsView.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTester/Views/UserCredentialsView.swift @@ -28,103 +28,188 @@ import SwiftUI import SalesforceSDKCore +// MARK: - Labels Constants +public struct CredentialsLabels { + // Section titles + public static let userIdentity = "User Identity" + public static let oauthClientConfiguration = "OAuth Client Configuration" + public static let tokens = "Tokens" + public static let urls = "URLs" + public static let community = "Community" + public static let domainsAndSids = "Domains and SIDs" + public static let cookiesAndSecurity = "Cookies and Security" + public static let beacon = "Beacon" + public static let other = "Other" + + // User Identity fields + public static let username = "Username" + public static let userIdLabel = "User ID" + public static let organizationId = "Organization ID" + + // OAuth Client Configuration fields + public static let clientId = "Client ID" + public static let redirectUri = "Redirect URI" + public static let protocolLabel = "Protocol" + public static let domain = "Domain" + public static let identifier = "Identifier" + + // Tokens fields + public static let accessToken = "Access Token" + public static let refreshToken = "Refresh Token" + public static let tokenFormat = "Token Format" + public static let jwt = "JWT" + public static let authCode = "Auth Code" + public static let challengeString = "Challenge String" + public static let issuedAt = "Issued At" + public static let scopes = "Scopes" + + // URLs fields + public static let instanceUrl = "Instance URL" + public static let apiInstanceUrl = "API Instance URL" + public static let apiUrl = "API URL" + public static let identityUrl = "Identity URL" + + // Community fields + public static let communityId = "Community ID" + public static let communityUrl = "Community URL" + + // Domains and SIDs fields + public static let lightningDomain = "Lightning Domain" + public static let lightningSid = "Lightning SID" + public static let vfDomain = "VF Domain" + public static let vfSid = "VF SID" + public static let contentDomain = "Content Domain" + public static let contentSid = "Content SID" + public static let parentSid = "Parent SID" + public static let sidCookieName = "SID Cookie Name" + + // Cookies and Security fields + public static let csrfToken = "CSRF Token" + public static let cookieClientSrc = "Cookie Client Src" + public static let cookieSidClient = "Cookie SID Client" + + // Beacon fields + public static let beaconChildConsumerKey = "Beacon Child Consumer Key" + public static let beaconChildConsumerSecret = "Beacon Child Consumer Secret" + + // Other fields + public static let additionalOAuthFields = "Additional OAuth Fields" +} + struct UserCredentialsView: View { @Binding var isExpanded: Bool let refreshTrigger: UUID - // Section expansion states - all start collapsed - @State private var userIdentityExpanded = false - @State private var oauthConfigExpanded = false - @State private var tokensExpanded = false - @State private var urlsExpanded = false - @State private var communityExpanded = false - @State private var domainsAndSidsExpanded = false - @State private var cookiesAndSecurityExpanded = false - @State private var beaconExpanded = false - @State private var otherExpanded = false + // Section expansion states - all expand/collapse together with parent + @State private var userIdentityExpanded = true + @State private var oauthConfigExpanded = true + @State private var tokensExpanded = true + @State private var urlsExpanded = true + @State private var communityExpanded = true + @State private var domainsAndSidsExpanded = true + @State private var cookiesAndSecurityExpanded = true + @State private var beaconExpanded = true + @State private var otherExpanded = true + + // Export alert state + @State private var showExportAlert = false + @State private var exportedJSON = "" var body: some View { VStack(alignment: .leading, spacing: 8) { - Button(action: { - withAnimation { - isExpanded.toggle() + HStack { + Button(action: { + withAnimation { + isExpanded.toggle() + // Expand/collapse all subsections with the parent + expandAllSubsections(isExpanded) + } + }) { + HStack { + Text("User Credentials") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundColor(.secondary) + } } - }) { - HStack { - Text("User Credentials") - .font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - Spacer() - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundColor(.secondary) + Button(action: { + exportedJSON = generateCredentialsJSON() + showExportAlert = true + }) { + Image(systemName: "square.and.arrow.up") + .font(.subheadline) + .foregroundColor(.blue) } + .accessibilityIdentifier("exportCredentialsButton") } if isExpanded { VStack(spacing: 8) { - InfoSectionView(title: "User Identity", isExpanded: $userIdentityExpanded) { - InfoRowView(label: "Username:", value: username) - InfoRowView(label: "User ID:", value: userId) - InfoRowView(label: "Organization ID:", value: organizationId) + InfoSectionView(title: CredentialsLabels.userIdentity, isExpanded: $userIdentityExpanded) { + InfoRowView(label: "\(CredentialsLabels.username):", value: username) + InfoRowView(label: "\(CredentialsLabels.userIdLabel):", value: usernameId) + InfoRowView(label: "\(CredentialsLabels.organizationId):", value: organizationId) } - InfoSectionView(title: "OAuth Client Configuration", isExpanded: $oauthConfigExpanded) { - InfoRowView(label: "Client ID:", value: clientId, isSensitive: true) - InfoRowView(label: "Redirect URI:", value: redirectUri) - InfoRowView(label: "Protocol:", value: authProtocol) - InfoRowView(label: "Domain:", value: domain) - InfoRowView(label: "Identifier:", value: identifier) + InfoSectionView(title: CredentialsLabels.oauthClientConfiguration, isExpanded: $oauthConfigExpanded) { + InfoRowView(label: "\(CredentialsLabels.clientId):", value: clientId, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.redirectUri):", value: redirectUri) + InfoRowView(label: "\(CredentialsLabels.protocolLabel):", value: authProtocol) + InfoRowView(label: "\(CredentialsLabels.domain):", value: domain) + InfoRowView(label: "\(CredentialsLabels.identifier):", value: identifier) } - InfoSectionView(title: "Tokens", isExpanded: $tokensExpanded) { - InfoRowView(label: "Access Token:", value: accessToken, isSensitive: true) - InfoRowView(label: "Refresh Token:", value: refreshToken, isSensitive: true) - InfoRowView(label: "Token Format:", value: tokenFormat) - InfoRowView(label: "JWT:", value: jwt, isSensitive: true) - InfoRowView(label: "Auth Code:", value: authCode, isSensitive: true) - InfoRowView(label: "Challenge String:", value: challengeString, isSensitive: true) - InfoRowView(label: "Issued At:", value: issuedAt) - InfoRowView(label: "Scopes:", value: credentialsScopes) + InfoSectionView(title: CredentialsLabels.tokens, isExpanded: $tokensExpanded) { + InfoRowView(label: "\(CredentialsLabels.accessToken):", value: accessToken, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.refreshToken):", value: refreshToken, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.tokenFormat):", value: tokenFormat) + InfoRowView(label: "\(CredentialsLabels.jwt):", value: jwt, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.authCode):", value: authCode, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.challengeString):", value: challengeString, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.issuedAt):", value: issuedAt) + InfoRowView(label: "\(CredentialsLabels.scopes):", value: credentialsScopes) } - InfoSectionView(title: "URLs", isExpanded: $urlsExpanded) { - InfoRowView(label: "Instance URL:", value: instanceUrl) - InfoRowView(label: "API Instance URL:", value: apiInstanceUrl) - InfoRowView(label: "API URL:", value: apiUrl) - InfoRowView(label: "Identity URL:", value: identityUrl) + InfoSectionView(title: CredentialsLabels.urls, isExpanded: $urlsExpanded) { + InfoRowView(label: "\(CredentialsLabels.instanceUrl):", value: instanceUrl) + InfoRowView(label: "\(CredentialsLabels.apiInstanceUrl):", value: apiInstanceUrl) + InfoRowView(label: "\(CredentialsLabels.apiUrl):", value: apiUrl) + InfoRowView(label: "\(CredentialsLabels.identityUrl):", value: identityUrl) } - InfoSectionView(title: "Community", isExpanded: $communityExpanded) { - InfoRowView(label: "Community ID:", value: communityId) - InfoRowView(label: "Community URL:", value: communityUrl) + InfoSectionView(title: CredentialsLabels.community, isExpanded: $communityExpanded) { + InfoRowView(label: "\(CredentialsLabels.communityId):", value: communityId) + InfoRowView(label: "\(CredentialsLabels.communityUrl):", value: communityUrl) } - InfoSectionView(title: "Domains and SIDs", isExpanded: $domainsAndSidsExpanded) { - InfoRowView(label: "Lightning Domain:", value: lightningDomain) - InfoRowView(label: "Lightning SID:", value: lightningSid, isSensitive: true) - InfoRowView(label: "VF Domain:", value: vfDomain) - InfoRowView(label: "VF SID:", value: vfSid, isSensitive: true) - InfoRowView(label: "Content Domain:", value: contentDomain) - InfoRowView(label: "Content SID:", value: contentSid, isSensitive: true) - InfoRowView(label: "Parent SID:", value: parentSid, isSensitive: true) - InfoRowView(label: "SID Cookie Name:", value: sidCookieName) + InfoSectionView(title: CredentialsLabels.domainsAndSids, isExpanded: $domainsAndSidsExpanded) { + InfoRowView(label: "\(CredentialsLabels.lightningDomain):", value: lightningDomain) + InfoRowView(label: "\(CredentialsLabels.lightningSid):", value: lightningSid, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.vfDomain):", value: vfDomain) + InfoRowView(label: "\(CredentialsLabels.vfSid):", value: vfSid, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.contentDomain):", value: contentDomain) + InfoRowView(label: "\(CredentialsLabels.contentSid):", value: contentSid, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.parentSid):", value: parentSid, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.sidCookieName):", value: sidCookieName) } - InfoSectionView(title: "Cookies and Security", isExpanded: $cookiesAndSecurityExpanded) { - InfoRowView(label: "CSRF Token:", value: csrfToken, isSensitive: true) - InfoRowView(label: "Cookie Client Src:", value: cookieClientSrc) - InfoRowView(label: "Cookie SID Client:", value: cookieSidClient, isSensitive: true) + InfoSectionView(title: CredentialsLabels.cookiesAndSecurity, isExpanded: $cookiesAndSecurityExpanded) { + InfoRowView(label: "\(CredentialsLabels.csrfToken):", value: csrfToken, isSensitive: true) + InfoRowView(label: "\(CredentialsLabels.cookieClientSrc):", value: cookieClientSrc) + InfoRowView(label: "\(CredentialsLabels.cookieSidClient):", value: cookieSidClient, isSensitive: true) } - InfoSectionView(title: "Beacon", isExpanded: $beaconExpanded) { - InfoRowView(label: "Beacon Child Consumer Key:", value: beaconChildConsumerKey) - InfoRowView(label: "Beacon Child Consumer Secret:", value: beaconChildConsumerSecret, isSensitive: true) + InfoSectionView(title: CredentialsLabels.beacon, isExpanded: $beaconExpanded) { + InfoRowView(label: "\(CredentialsLabels.beaconChildConsumerKey):", value: beaconChildConsumerKey) + InfoRowView(label: "\(CredentialsLabels.beaconChildConsumerSecret):", value: beaconChildConsumerSecret, isSensitive: true) } - InfoSectionView(title: "Other", isExpanded: $otherExpanded) { - InfoRowView(label: "Additional OAuth Fields:", value: additionalOAuthFields) + InfoSectionView(title: CredentialsLabels.other, isExpanded: $otherExpanded) { + InfoRowView(label: "\(CredentialsLabels.additionalOAuthFields):", value: additionalOAuthFields) } } .id(refreshTrigger) @@ -133,6 +218,111 @@ struct UserCredentialsView: View { .padding() .background(Color(.secondarySystemBackground)) .cornerRadius(8) + .alert("Credentials JSON", isPresented: $showExportAlert) { + Button("Copy to Clipboard") { + UIPasteboard.general.string = exportedJSON + } + Button("OK", role: .cancel) { } + } message: { + Text(exportedJSON) + } + } + + // MARK: - Helper Methods + + private func expandAllSubsections(_ expand: Bool) { + userIdentityExpanded = expand + oauthConfigExpanded = expand + tokensExpanded = expand + urlsExpanded = expand + communityExpanded = expand + domainsAndSidsExpanded = expand + cookiesAndSecurityExpanded = expand + beaconExpanded = expand + otherExpanded = expand + } + + private func generateCredentialsJSON() -> String { + var result: [String: [String: String]] = [:] + + // User Identity section + result[CredentialsLabels.userIdentity] = [ + CredentialsLabels.username: username, + CredentialsLabels.userIdLabel: usernameId, + CredentialsLabels.organizationId: organizationId + ] + + // OAuth Client Configuration section + result[CredentialsLabels.oauthClientConfiguration] = [ + CredentialsLabels.clientId: clientId, + CredentialsLabels.redirectUri: redirectUri, + CredentialsLabels.protocolLabel: authProtocol, + CredentialsLabels.domain: domain, + CredentialsLabels.identifier: identifier + ] + + // Tokens section + result[CredentialsLabels.tokens] = [ + CredentialsLabels.accessToken: accessToken, + CredentialsLabels.refreshToken: refreshToken, + CredentialsLabels.tokenFormat: tokenFormat, + CredentialsLabels.jwt: jwt, + CredentialsLabels.authCode: authCode, + CredentialsLabels.challengeString: challengeString, + CredentialsLabels.issuedAt: issuedAt, + CredentialsLabels.scopes: credentialsScopes + ] + + // URLs section + result[CredentialsLabels.urls] = [ + CredentialsLabels.instanceUrl: instanceUrl, + CredentialsLabels.apiInstanceUrl: apiInstanceUrl, + CredentialsLabels.apiUrl: apiUrl, + CredentialsLabels.identityUrl: identityUrl + ] + + // Community section + result[CredentialsLabels.community] = [ + CredentialsLabels.communityId: communityId, + CredentialsLabels.communityUrl: communityUrl + ] + + // Domains and SIDs section + result[CredentialsLabels.domainsAndSids] = [ + CredentialsLabels.lightningDomain: lightningDomain, + CredentialsLabels.lightningSid: lightningSid, + CredentialsLabels.vfDomain: vfDomain, + CredentialsLabels.vfSid: vfSid, + CredentialsLabels.contentDomain: contentDomain, + CredentialsLabels.contentSid: contentSid, + CredentialsLabels.parentSid: parentSid, + CredentialsLabels.sidCookieName: sidCookieName + ] + + // Cookies and Security section + result[CredentialsLabels.cookiesAndSecurity] = [ + CredentialsLabels.csrfToken: csrfToken, + CredentialsLabels.cookieClientSrc: cookieClientSrc, + CredentialsLabels.cookieSidClient: cookieSidClient + ] + + // Beacon section + result[CredentialsLabels.beacon] = [ + CredentialsLabels.beaconChildConsumerKey: beaconChildConsumerKey, + CredentialsLabels.beaconChildConsumerSecret: beaconChildConsumerSecret + ] + + // Other section + result[CredentialsLabels.other] = [ + CredentialsLabels.additionalOAuthFields: additionalOAuthFields + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted]), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return "{}" + } + + return jsonString } // MARK: - Computed Properties @@ -146,7 +336,7 @@ struct UserCredentialsView: View { return UserAccountManager.shared.currentUserAccount?.idData.username ?? "" } - private var userId: String { + private var usernameId: String { return credentials?.userId ?? "" } @@ -212,7 +402,7 @@ struct UserCredentialsView: View { guard let scopes = credentials?.scopes else { return "" } - return scopes.joined(separator: " ") + return scopes.sorted().joined(separator: " ") } // URLs diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift new file mode 100644 index 0000000000..09db0f839e --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/AuthFlowTesterMainPageObject.swift @@ -0,0 +1,552 @@ +/* + AuthFlowTesterMainPageObject.swift + AuthFlowTesterUITests + + Copyright (c) 2025-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 + +// MARK: - Label Constants (mirroring the app's Labels structs for JSON parsing) + +struct CredentialsLabels { + // Section titles + static let userIdentity = "User Identity" + static let oauthClientConfiguration = "OAuth Client Configuration" + static let tokens = "Tokens" + static let urls = "URLs" + static let community = "Community" + static let domainsAndSids = "Domains and SIDs" + static let cookiesAndSecurity = "Cookies and Security" + static let beacon = "Beacon" + static let other = "Other" + + // User Identity fields + static let username = "Username" + static let userIdLabel = "User ID" + static let organizationId = "Organization ID" + + // OAuth Client Configuration fields + static let clientId = "Client ID" + static let redirectUri = "Redirect URI" + static let protocolLabel = "Protocol" + static let domain = "Domain" + static let identifier = "Identifier" + + // Tokens fields + static let accessToken = "Access Token" + static let refreshToken = "Refresh Token" + static let tokenFormat = "Token Format" + static let jwt = "JWT" + static let authCode = "Auth Code" + static let challengeString = "Challenge String" + static let issuedAt = "Issued At" + static let scopes = "Scopes" + + // URLs fields + static let instanceUrl = "Instance URL" + static let apiInstanceUrl = "API Instance URL" + static let apiUrl = "API URL" + static let identityUrl = "Identity URL" + + // Community fields + static let communityId = "Community ID" + static let communityUrl = "Community URL" + + // Domains and SIDs fields + static let lightningDomain = "Lightning Domain" + static let lightningSid = "Lightning SID" + static let vfDomain = "VF Domain" + static let vfSid = "VF SID" + static let contentDomain = "Content Domain" + static let contentSid = "Content SID" + static let parentSid = "Parent SID" + static let sidCookieName = "SID Cookie Name" + + // Cookies and Security fields + static let csrfToken = "CSRF Token" + static let cookieClientSrc = "Cookie Client Src" + static let cookieSidClient = "Cookie SID Client" + + // Beacon fields + static let beaconChildConsumerKey = "Beacon Child Consumer Key" + static let beaconChildConsumerSecret = "Beacon Child Consumer Secret" + + // Other fields + static let additionalOAuthFields = "Additional OAuth Fields" +} + +struct OAuthConfigLabels { + static let consumerKey = "Configured Consumer Key" + static let callbackUrl = "Configured Callback URL" + static let scopes = "Configured Scopes" +} + +struct JwtTokenLabels { + // Section titles + static let header = "Header" + static let payload = "Payload" + + // Header fields + static let algorithm = "Algorithm (alg)" + static let type = "Type (typ)" + static let keyId = "Key ID (kid)" + static let tokenType = "Token Type (tty)" + static let tenantKey = "Tenant Key (tnk)" + static let version = "Version (ver)" + + // Payload fields + static let audience = "Audience (aud)" + static let expirationDate = "Expiration Date (exp)" + static let issuer = "Issuer (iss)" + static let subject = "Subject (sub)" + static let scopes = "Scopes (scp)" + static let clientId = "Client ID (client_id)" +} + +// MARK: - Data Structures + +struct UserCredentialsData { + // User Identity + var username: String + var userId: String + var organizationId: String + + // OAuth Client Configuration + var clientId: String + var redirectUri: String + var authProtocol: String + var domain: String + var identifier: String + + // Tokens + var accessToken: String + var refreshToken: String + var tokenFormat: String + var jwt: String + var authCode: String + var challengeString: String + var issuedAt: String + var credentialsScopes: String + + // URLs + var instanceUrl: String + var apiInstanceUrl: String + var apiUrl: String + var identityUrl: String + + // Community + var communityId: String + var communityUrl: String + + // Domains and SIDs + var lightningDomain: String + var lightningSid: String + var vfDomain: String + var vfSid: String + var contentDomain: String + var contentSid: String + var parentSid: String + var sidCookieName: String + + // Cookies and Security + var csrfToken: String + var cookieClientSrc: String + var cookieSidClient: String + + // Beacon + var beaconChildConsumerKey: String + var beaconChildConsumerSecret: String + + // Other + var additionalOAuthFields: String +} + +struct OAuthConfigurationData { + var configuredConsumerKey: String + var configuredCallbackUrl: String + var configuredScopes: String +} + +struct JwtDetailsData { + // Header + var algorithm: String + var type: String + var keyId: String + var tokenType: String + var tenantKey: String + var version: String + + // Payload + var audience: String + var expirationDate: String + var issuer: String + var subject: String + var scopes: String + var clientId: String +} + +/// Page object for interacting with the AuthFlowTester main screen during UI tests. +/// Provides methods to perform actions (revoke access token, make REST requests, change consumer key, change users, logout). +/// and extract data (user credentials, OAuth configuration, JWT details) from the UI. +class AuthFlowTesterMainPageObject { + let app: XCUIApplication + let timeout: double_t = 3 + + init(testApp: XCUIApplication) { + app = testApp + } + + func isShowing() -> Bool { + return navigationTitle().waitForExistence(timeout: timeout) + } + + func performLogout() { + tap(bottomBarLogoutButton()) + tap(alertLogoutButton()) + } + + func makeRestRequest() -> Bool { + tap(makeRestRequestButton()) + let alert = app.alerts["Request Successful"] + if (alert.waitForExistence(timeout: timeout)) { + alert.buttons["OK"].tap() + return true + } + return false + } + + func revokeAccessToken() -> Bool { + tap(revokeButton()) + let alert = app.alerts["Access Token Revoked"] + if (alert.waitForExistence(timeout: timeout)) { + alert.buttons["OK"].tap() + return true + } + return false + } + + func performAddUser() { + // Tap Switch User button to open the user management screen + tap(bottomBarSwitchUserButton()) + + // Tap "New User" button in the user list navigation bar + tap(newUserButton()) + } + + func switchToUser(username: String) { + // Tap Switch User button to open the user management screen + tap(bottomBarSwitchUserButton()) + + // Tap the row containing the username + tap(userRow(username: username)) + + // Tap "Switch to User" button + tap(swithToUserButton()) + } + + func changeAppConfig(appConfig: AppConfig, scopesToRequest: String = "") -> Bool { + // Tap Change Key button to open the sheet + tap(bottomBarChangeKeyButton()) + + // Fill in the text fields + setTextField(consumerKeyTextField(), value: appConfig.consumerKey) + setTextField(callbackUrlTextField(), value: appConfig.redirectUri) + setTextField(scopesTextField(), value: scopesToRequest) + + // 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: timeout)) { + alert.buttons["OK"].tap() + return false + } + return true + } + + // MARK: - UI Element Accessors + + private func navigationTitle() -> XCUIElement { + return app.navigationBars["AuthFlowTester"] + } + + private func revokeButton() -> XCUIElement { + return app.buttons["Revoke Access Token"] + } + + private func makeRestRequestButton() -> XCUIElement { + return app.buttons["Make REST API Request"] + } + + private func bottomBarChangeKeyButton() -> XCUIElement { + return app.buttons["Change Key"] + } + + private func bottomBarSwitchUserButton() -> XCUIElement { + return app.buttons["Switch User"] + } + + private func bottomBarLogoutButton() -> XCUIElement { + return app.buttons["Logout"] + } + + private func alertLogoutButton() -> XCUIElement { + return app.alerts["Logout"].buttons["Logout"] + } + + private func userCredentialsSection() -> XCUIElement { + return app.buttons["User Credentials"] + } + + private func oauthConfigSection() -> XCUIElement { + return app.buttons["OAuth Configuration"] + } + + private func jwtDetailsSection() -> XCUIElement { + return app.buttons["JWT Access Token Details"] + } + + // Export buttons + + private func exportCredentialsButton() -> XCUIElement { + return app.buttons["exportCredentialsButton"].firstMatch + } + + private func exportOAuthConfigButton() -> XCUIElement { + return app.buttons["exportOAuthConfigButton"].firstMatch + } + + private func exportJwtTokenButton() -> XCUIElement { + return app.buttons["exportJwtTokenButton"].firstMatch + } + + // Refresh token migration + + private func newAppConfigurationSection() -> XCUIElement { + return app.buttons["New App Configuration"] + } + + private func consumerKeyTextField() -> XCUIElement { + return app.textFields["consumerKeyTextField"] + } + + private func callbackUrlTextField() -> XCUIElement { + return app.textFields["callbackUrlTextField"] + } + + private func scopesTextField() -> XCUIElement { + return app.textFields["scopesTextField"] + } + + private func migrateRefreshTokenButton() -> XCUIElement { + return app.buttons["Migrate refresh token"] + } + + private func allowButton() -> XCUIElement { + let buttons = app.webViews.webViews.webViews.buttons + let predicate = NSPredicate(format: "label CONTAINS[c] 'Allow'") + return buttons.matching(predicate).firstMatch + } + + // User switching + + private func newUserButton() -> XCUIElement { + return app.navigationBars["User List"].buttons["New User"] + } + + private func userRow(username: String) -> XCUIElement { + return app.staticTexts[username] + } + + private func swithToUserButton() -> XCUIElement { + return app.buttons["Switch to User"] + } + + // MARK: - Actions + + private func tap(_ element: XCUIElement) { + _ = element.waitForExistence(timeout: timeout) + element.tap() + } + + private func tapIfPresent(_ element: XCUIElement) { + if (element.waitForExistence(timeout: timeout)) { + element.tap() + } + } + + private func setTextField(_ textField: XCUIElement, value: String) { + _ = textField.waitForExistence(timeout: timeout) + textField.tap() + + // Clear any existing text + if let currentValue = textField.value as? String, !currentValue.isEmpty { + textField.tap() + let selectAll = app.menuItems["Select All"] + if selectAll.waitForExistence(timeout: 1) { + selectAll.tap() + textField.typeText(XCUIKeyboardKey.delete.rawValue) + } + } + + textField.typeText(value) + } + + // MARK: - Data Extraction Methods + + func getUserCredentials() -> UserCredentialsData { + // Tap export button and get JSON + let json = tapExportAndGetJSON(exportCredentialsButton(), alertTitle: "Credentials JSON") + + // Parse JSON sections + let userIdentity = json[CredentialsLabels.userIdentity] as? [String: String] ?? [:] + let oauthConfig = json[CredentialsLabels.oauthClientConfiguration] as? [String: String] ?? [:] + let tokens = json[CredentialsLabels.tokens] as? [String: String] ?? [:] + let urls = json[CredentialsLabels.urls] as? [String: String] ?? [:] + let community = json[CredentialsLabels.community] as? [String: String] ?? [:] + let domainsAndSids = json[CredentialsLabels.domainsAndSids] as? [String: String] ?? [:] + let cookiesAndSecurity = json[CredentialsLabels.cookiesAndSecurity] as? [String: String] ?? [:] + let beacon = json[CredentialsLabels.beacon] as? [String: String] ?? [:] + let other = json[CredentialsLabels.other] as? [String: String] ?? [:] + + return UserCredentialsData( + username: userIdentity[CredentialsLabels.username] ?? "", + userId: userIdentity[CredentialsLabels.userIdLabel] ?? "", + organizationId: userIdentity[CredentialsLabels.organizationId] ?? "", + clientId: oauthConfig[CredentialsLabels.clientId] ?? "", + redirectUri: oauthConfig[CredentialsLabels.redirectUri] ?? "", + authProtocol: oauthConfig[CredentialsLabels.protocolLabel] ?? "", + domain: oauthConfig[CredentialsLabels.domain] ?? "", + identifier: oauthConfig[CredentialsLabels.identifier] ?? "", + accessToken: tokens[CredentialsLabels.accessToken] ?? "", + refreshToken: tokens[CredentialsLabels.refreshToken] ?? "", + tokenFormat: tokens[CredentialsLabels.tokenFormat] ?? "", + jwt: tokens[CredentialsLabels.jwt] ?? "", + authCode: tokens[CredentialsLabels.authCode] ?? "", + challengeString: tokens[CredentialsLabels.challengeString] ?? "", + issuedAt: tokens[CredentialsLabels.issuedAt] ?? "", + credentialsScopes: tokens[CredentialsLabels.scopes] ?? "", + instanceUrl: urls[CredentialsLabels.instanceUrl] ?? "", + apiInstanceUrl: urls[CredentialsLabels.apiInstanceUrl] ?? "", + apiUrl: urls[CredentialsLabels.apiUrl] ?? "", + identityUrl: urls[CredentialsLabels.identityUrl] ?? "", + communityId: community[CredentialsLabels.communityId] ?? "", + communityUrl: community[CredentialsLabels.communityUrl] ?? "", + lightningDomain: domainsAndSids[CredentialsLabels.lightningDomain] ?? "", + lightningSid: domainsAndSids[CredentialsLabels.lightningSid] ?? "", + vfDomain: domainsAndSids[CredentialsLabels.vfDomain] ?? "", + vfSid: domainsAndSids[CredentialsLabels.vfSid] ?? "", + contentDomain: domainsAndSids[CredentialsLabels.contentDomain] ?? "", + contentSid: domainsAndSids[CredentialsLabels.contentSid] ?? "", + parentSid: domainsAndSids[CredentialsLabels.parentSid] ?? "", + sidCookieName: domainsAndSids[CredentialsLabels.sidCookieName] ?? "", + csrfToken: cookiesAndSecurity[CredentialsLabels.csrfToken] ?? "", + cookieClientSrc: cookiesAndSecurity[CredentialsLabels.cookieClientSrc] ?? "", + cookieSidClient: cookiesAndSecurity[CredentialsLabels.cookieSidClient] ?? "", + beaconChildConsumerKey: beacon[CredentialsLabels.beaconChildConsumerKey] ?? "", + beaconChildConsumerSecret: beacon[CredentialsLabels.beaconChildConsumerSecret] ?? "", + additionalOAuthFields: other[CredentialsLabels.additionalOAuthFields] ?? "" + ) + } + + func getOAuthConfiguration() -> OAuthConfigurationData { + // Tap export button and get JSON + let json = tapExportAndGetJSON(exportOAuthConfigButton(), alertTitle: "OAuth Configuration JSON") + + return OAuthConfigurationData( + configuredConsumerKey: json[OAuthConfigLabels.consumerKey] as? String ?? "", + configuredCallbackUrl: json[OAuthConfigLabels.callbackUrl] as? String ?? "", + configuredScopes: json[OAuthConfigLabels.scopes] as? String ?? "" + ) + } + + func getJwtDetails() -> JwtDetailsData? { + // Check if JWT export button exists (indicates JWT token is available) + guard exportJwtTokenButton().waitForExistence(timeout: 1) else { + return nil + } + + // Tap export button and get JSON + let json = tapExportAndGetJSON(exportJwtTokenButton(), alertTitle: "JWT Token JSON") + + // Parse JSON sections + let header = json[JwtTokenLabels.header] as? [String: String] ?? [:] + let payload = json[JwtTokenLabels.payload] as? [String: String] ?? [:] + + return JwtDetailsData( + algorithm: header[JwtTokenLabels.algorithm] ?? "", + type: header[JwtTokenLabels.type] ?? "", + keyId: header[JwtTokenLabels.keyId] ?? "", + tokenType: header[JwtTokenLabels.tokenType] ?? "", + tenantKey: header[JwtTokenLabels.tenantKey] ?? "", + version: header[JwtTokenLabels.version] ?? "", + audience: payload[JwtTokenLabels.audience] ?? "", + expirationDate: payload[JwtTokenLabels.expirationDate] ?? "", + issuer: payload[JwtTokenLabels.issuer] ?? "", + subject: payload[JwtTokenLabels.subject] ?? "", + scopes: payload[JwtTokenLabels.scopes] ?? "", + clientId: payload[JwtTokenLabels.clientId] ?? "" + ) + } + + // MARK: - Private Helper Methods for Data Extraction + + /// Taps the export button and returns the parsed JSON from the alert + private func tapExportAndGetJSON(_ exportButton: XCUIElement, alertTitle: String) -> [String: Any] { + // Tap the export button + tap(exportButton) + + // Wait for and get the alert + let alert = app.alerts[alertTitle] + guard alert.waitForExistence(timeout: timeout) else { + return [:] + } + + // Get the message text from the alert (contains the JSON) + let jsonString = alert.staticTexts.element(boundBy: 1).label + + // Dismiss the alert + alert.buttons["OK"].tap() + + // Parse the JSON + guard let jsonData = jsonString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + return [:] + } + + return json + } + + // MARK: - Other + + private func hasStaticText(_ text: String) -> Bool { + let staticText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(text)'")) + return staticText.firstMatch.waitForExistence(timeout: timeout) + } +} + diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift new file mode 100644 index 0000000000..14cd0009c3 --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift @@ -0,0 +1,303 @@ +/* + LoginPageObject.swift + AuthFlowTesterUITests + + Copyright (c) 2025-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 Salesforce login screen during UI tests. +/// Provides methods to configure login servers, login options and perform user authentication. +class LoginPageObject { + let app: XCUIApplication + let timeout: double_t = 5 + + init(testApp: XCUIApplication) { + app = testApp + } + + func isShowing() -> Bool { + return loginNavigationBar().waitForExistence(timeout: timeout) + } + + func configureLoginHost(host: String) -> Void { + tap(settingsButton()) + tap(changeServerButton()) + + if (hasHost(host: host)) { + // Select host if it exists already + tap(hostRow(host: host)) + } else { + // Add host if it does not exist + tap(addConnectionButton()) + setTextField(hostInputField(), value: host) + tap(doneOnAddConnectionButton()) + } + } + + func performLogin(username: String, password: String) { + setTextField(usernameField(), value: username) + setTextField(passwordField(), value: password) + tap(loginButton()) + tapIfPresent(allowButton()) + } + + func configureLoginOptions( + staticAppConfig: AppConfig?, + staticScopes: String, + dynamicAppConfig: AppConfig?, + dynamicScopes: String, + useWebServerFlow: Bool, + useHybridFlow: Bool, + supportWelcomeDiscovery: Bool, + ) -> Void { + tap(settingsButton()) + tap(loginOptionsButton()) + setSwitchField(useWebServerFlowSwitch(), value: useWebServerFlow) + setSwitchField(useHybridSwitch(), value: useHybridFlow) + setSwitchField(supportWelcomeDiscoverySwitch(), value: supportWelcomeDiscovery) + + if let staticAppConfig = staticAppConfig { + let configJSON = buildConfigJSON(consumerKey: staticAppConfig.consumerKey, redirectUri: staticAppConfig.redirectUri, scopes: staticScopes) + importConfig(configJSON, isStaticConfiguration: true) + } + // In all cases - we want the static config to be set + tap(useStaticConfigButton()) + + // Setting dynamic config when provided + if let dynamicAppConfig = dynamicAppConfig { + tap(settingsButton()) + tap(loginOptionsButton()) + let configJSON = buildConfigJSON(consumerKey: dynamicAppConfig.consumerKey, redirectUri: dynamicAppConfig.redirectUri, scopes: dynamicScopes) + importConfig(configJSON, isStaticConfiguration: false) + tap(useDynamicConfigButton()) + } + } + + private func buildConfigJSON(consumerKey: String, redirectUri: String, scopes: String) -> String { + let config: [String: String] = [ + BootConfigJSONKeys.consumerKey: consumerKey, + BootConfigJSONKeys.redirectUri: redirectUri, + BootConfigJSONKeys.scopes: scopes + ] + guard let jsonData = try? JSONSerialization.data(withJSONObject: config, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return "{}" + } + return jsonString + } + + private func importConfig(_ jsonString: String, isStaticConfiguration: Bool = true) { + tap(importConfigButton(useStaticConfiguration: isStaticConfiguration)) + + // Wait for alert to appear + let alert = importConfigAlert() + _ = alert.waitForExistence(timeout: timeout) + + // Type into the alert's text field + let textField = importConfigTextField() + textField.typeText(jsonString) + + tap(importAlertButton()) + } + + // MARK: - UI Element Accessors + + private func loginNavigationBar() -> XCUIElement { + return app.navigationBars["Log In"] + } + + private func settingsButton() -> XCUIElement { + return loginNavigationBar().buttons["Settings"] + } + + private func changeServerButton() -> XCUIElement { + return app.buttons["Change Server"] + } + + private func loginOptionsButton() -> XCUIElement { + return app.buttons["Login Options"] + } + + private func changeServerNavigationBar() -> XCUIElement { + return app.navigationBars["Change Server"] + } + + private func addConnectionButton() -> XCUIElement { + return changeServerNavigationBar().buttons["Add"] + } + + private func addConnectionNavigationBar() -> XCUIElement { + return app.navigationBars["Add Connection"] + } + + private func hostInputField() -> XCUIElement { + return app.textFields["addconn_hostInput"] + } + + private func doneOnAddConnectionButton() -> XCUIElement { + return addConnectionNavigationBar().buttons["Done"] + } + + private func hostRow(host: String) -> XCUIElement { + return app.staticTexts[host].firstMatch + } + + private func usernameField() -> XCUIElement { + return app.descendants(matching: .textField).element + } + + private func passwordField() -> XCUIElement { + return app.descendants(matching: .secureTextField).element + } + + private func loginButton() -> XCUIElement { + return app.webViews.webViews.webViews.buttons["Log In"] + } + + private func allowButton() -> XCUIElement { + let buttons = app.webViews.webViews.webViews.buttons + let predicate = NSPredicate(format: "label CONTAINS[c] 'Allow'") + return buttons.matching(predicate).firstMatch + } + + private func toolbarDoneButton() -> XCUIElement { + return app.toolbars["Toolbar"].buttons["Done"] + } + + private func useWebServerFlowSwitch() -> XCUIElement { + return app.switches["Use Web Server Flow"] + } + + private func useHybridSwitch() -> XCUIElement { + return app.switches["Use Hybrid Flow"] + } + + private func supportWelcomeDiscoverySwitch() -> XCUIElement { + return app.switches["Support Welcome Discovery"] + } + + private func staticConfigurationSection() -> XCUIElement { + return app.buttons["Static Configuration"] + } + + private func consumerKeyField() -> XCUIElement { + return app.textFields["consumerKeyTextField"] + } + + private func callbackUrlField() -> XCUIElement { + return app.textFields["callbackUrlTextField"] + } + + private func scopesField() -> XCUIElement { + return app.textFields["scopesTextField"] + } + + private func useStaticConfigButton() -> XCUIElement { + return app.buttons["Use static config"] + } + + private func useDynamicConfigButton() -> XCUIElement { + return app.buttons["Use dynamic config"] + } + + /// Returns the import button for either the static or dynamic configuration section. + /// The BootConfigPickerView has two BootConfigEditor sections - the first for static config, the second for dynamic config. + private func importConfigButton(useStaticConfiguration: Bool = true) -> XCUIElement { + let buttons = app.buttons.matching(identifier: "importConfigButton") + let index = useStaticConfiguration ? 0 : 1 + return buttons.element(boundBy: index) + } + + private func importConfigAlert() -> XCUIElement { + return app.alerts["Import Configuration"] + } + + private func importConfigTextField() -> XCUIElement { + // Access text field through the alert - SwiftUI alert TextFields are accessed this way + return importConfigAlert().textFields.firstMatch + } + + private func importAlertButton() -> XCUIElement { + return importConfigAlert().buttons["Import"] + } + + // MARK: - Actions + + private func tap(_ element: XCUIElement) { + _ = element.waitForExistence(timeout: timeout) + element.tap() + } + + private func tapIfPresent(_ element: XCUIElement) { + if (element.waitForExistence(timeout: timeout)) { + element.tap() + } + } + + private func setTextField(_ textField: XCUIElement, value: String) { + tap(textField) + + // Return if the value is already set + if textField.value as? String == value { + return + } + + // Clear any existing text + if let currentValue = textField.value as? String, !currentValue.isEmpty { + tap(textField) // second tap should bring up menu + let selectAll = app.menuItems["Select All"] + if selectAll.waitForExistence(timeout: 1) { + selectAll.tap() + textField.typeText(XCUIKeyboardKey.delete.rawValue) + } + } + + textField.typeText(value) + tapIfPresent(toolbarDoneButton()) + } + + private func setSwitchField(_ switchField: XCUIElement, value: Bool) { + _ = switchField.waitForExistence(timeout: timeout) + + // Switch values are "0" (off) or "1" (on) in XCTest + let currentValue = (switchField.value as? String) == "1" + + // Only tap if the current state differs from desired state + if currentValue != value { + tap(switchField) + } + } + + // MARK: - Other + + private func hasHost(host: String) -> Bool { + let row = hostRow(host: host) + return row.waitForExistence(timeout: timeout) + } + +} + diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/BaseAuthFlowTesterTest.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/BaseAuthFlowTesterTest.swift new file mode 100644 index 0000000000..4d88554af2 --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/BaseAuthFlowTesterTest.swift @@ -0,0 +1,636 @@ +/* + BaseAuthFlowTesterTest.swift + AuthFlowTesterUITests + + Copyright (c) 2025-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 + +class BaseAuthFlowTesterTest: XCTestCase { + // App object + private var app: XCUIApplication! + + // App Pages + private var loginPage: LoginPageObject! + private var mainPage: AuthFlowTesterMainPageObject! + + // Test configuration + private let testConfig = TestConfigUtils.shared + private let host: String = TestConfigUtils.shared.loginHostNoProtocol ?? "" + private var loginHostConfiguredAlready = false + + override func setUp() { + super.setUp() + continueAfterFailure = false + + guard host != "" else { + XCTFail("No login host configured") + fatalError("No login host configured") + } + } + + override func tearDown() { + logout() + super.tearDown() + } + + // MARK: - Public API for Subclasses + + /// Launches the application and ensures it starts in a logged-out state. + /// + /// Initializes the app and page objects, launches the app, and logs out if a user is already logged in. + func launch() { + app = XCUIApplication() + loginPage = LoginPageObject(testApp: app) + mainPage = AuthFlowTesterMainPageObject(testApp: app) + app.launch() + + // Start logged out + if (mainPage.isShowing()) { + logout() + } + } + + /// Performs login with the specified configuration. + /// + /// Configures the login options and performs authentication for the specified user. + /// Must be called after `launch()`. + /// + /// - Parameters: + /// - user: The user to log in with. + /// - staticAppConfigName: The static app configuration name. + /// - staticScopeSelection: The scope selection for static configuration. Defaults to `.empty`. + /// - dynamicAppConfigName: Optional dynamic app configuration name (provided at runtime). + /// - dynamicScopeSelection: The scope selection for dynamic configuration. Defaults to `.empty`. + /// - useWebServerFlow: Whether to use web server OAuth flow. Defaults to `true`. + /// - useHybridFlow: Whether to use hybrid authentication flow. Defaults to `true`. + /// - supportWelcomeDiscovery: Whether to support welcome/discovery screen. Defaults to `false`. + func login( + user: KnownUserConfig, + staticAppConfigName: KnownAppConfig, + staticScopeSelection: ScopeSelection = .empty, + dynamicAppConfigName: KnownAppConfig? = nil, + dynamicScopeSelection: ScopeSelection = .empty, + useWebServerFlow: Bool = true, + useHybridFlow: Bool = true, + supportWelcomeDiscovery: Bool = false + ) { + // To speed up things a bit - only configuring login host once (it never changes) + if (!loginHostConfiguredAlready) { + loginPage.configureLoginHost(host: host) + loginHostConfiguredAlready = true + } + + let userConfig = getUser(user) + let staticAppConfig = getAppConfig(named: staticAppConfigName) + let dynamicAppConfig = dynamicAppConfigName == nil ? nil : getAppConfig(named: dynamicAppConfigName!) + let staticScopes = testConfig.getScopesToRequest(for: staticAppConfig, staticScopeSelection) + let dynamicScopes = dynamicAppConfig == nil ? "" : testConfig.getScopesToRequest(for: dynamicAppConfig!, dynamicScopeSelection) + + loginPage.configureLoginOptions( + staticAppConfig: staticAppConfig, + staticScopes: staticScopes, + dynamicAppConfig: dynamicAppConfig, + dynamicScopes: dynamicScopes, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow, + supportWelcomeDiscovery: supportWelcomeDiscovery + ) + + loginPage.performLogin(username: userConfig.username, password: userConfig.password) + } + + /// Logs out the current user by tapping the logout button and confirming. + /// + /// Safe to call even if the app was never launched (no-op in that case). + func logout() { + // In case the app was never launched + if (app != nil) { + mainPage.performLogout() + } + } + + /// Switches to an already logged-in user and validates the credentials. + /// + /// Use this method when multiple users are logged in and you want to switch between them. + /// + /// - Parameters: + /// - user: The user to switch to. + /// - staticAppConfigName: The static app configuration name. + /// - staticScopeSelection: The scope selection for static configuration. Defaults to `.empty`. + /// - userAppConfigName: The app configuration the user was logged in with. + /// - userScopeSelection: The scope selection the user was logged in with. Defaults to `.empty`. + /// - useWebServerFlow: Whether web server OAuth flow was used. Defaults to `true`. + /// - useHybridFlow: Whether hybrid authentication flow was used. Defaults to `true`. + func switchToUserAndValidate( + user: KnownUserConfig, + staticAppConfigName: KnownAppConfig, + staticScopeSelection: ScopeSelection = .empty, + userAppConfigName: KnownAppConfig, + userScopeSelection: ScopeSelection = .empty, + useWebServerFlow: Bool = true, + useHybridFlow: Bool = true + ) { + // Switch user + mainPage.switchToUser(username: getUser(user).username) + + // Validate + validate( + user: user, + staticAppConfigName: staticAppConfigName, + staticScopeSelection: staticScopeSelection, + userAppConfigName: userAppConfigName, + userScopeSelection: userScopeSelection, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow + ) + } + + /// Launches the app and performs login. + /// + /// This is a convenience method that combines `launch()` and `login()` in one call. + /// Use this for the initial login flow in tests. + /// + /// - Parameters: + /// - user: The user to log in with. + /// - staticAppConfigName: The static app configuration name. + /// - staticScopeSelection: The scope selection for static configuration. Defaults to `.empty`. + /// - dynamicAppConfigName: Optional dynamic app configuration name (provided at runtime). + /// - dynamicScopeSelection: The scope selection for dynamic configuration. Defaults to `.empty`. + /// - useWebServerFlow: Whether to use web server OAuth flow. Defaults to `true`. + /// - useHybridFlow: Whether to use hybrid authentication flow. Defaults to `true`. + /// - supportWelcomeDiscovery: Whether to support welcome/discovery screen. Defaults to `false`. + func launchAndLogin( + user: KnownUserConfig, + staticAppConfigName: KnownAppConfig, + staticScopeSelection: ScopeSelection = .empty, + dynamicAppConfigName: KnownAppConfig? = nil, + dynamicScopeSelection: ScopeSelection = .empty, + useWebServerFlow: Bool = true, + useHybridFlow: Bool = true, + supportWelcomeDiscovery: Bool = false + ) { + // Launch + launch() + + // Login + login( + user: user, + staticAppConfigName: staticAppConfigName, + staticScopeSelection: staticScopeSelection, + dynamicAppConfigName: dynamicAppConfigName, + dynamicScopeSelection: dynamicScopeSelection, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow, + supportWelcomeDiscovery: supportWelcomeDiscovery + ) + } + + /// Launches the app, performs login, and validates the resulting credentials. + /// + /// This is a convenience method that combines `launch()`, `login()`, and validation in one call. + /// Use this for the initial login flow in tests. + /// + /// - Parameters: + /// - user: The user to log in with. Defaults to `.first`. + /// - staticAppConfigName: The static app configuration name. + /// - staticScopeSelection: The scope selection for static configuration. Defaults to `.empty`. + /// - dynamicAppConfigName: Optional dynamic app configuration name (provided at runtime). + /// - dynamicScopeSelection: The scope selection for dynamic configuration. Defaults to `.empty`. + /// - useWebServerFlow: Whether to use web server OAuth flow. Defaults to `true`. + /// - useHybridFlow: Whether to use hybrid authentication flow. Defaults to `true`. + /// - supportWelcomeDiscovery: Whether to support welcome/discovery screen. Defaults to `false`. + func launchLoginAndValidate( + user: KnownUserConfig = .first, + staticAppConfigName: KnownAppConfig, + staticScopeSelection: ScopeSelection = .empty, + dynamicAppConfigName: KnownAppConfig? = nil, + dynamicScopeSelection: ScopeSelection = .empty, + useWebServerFlow: Bool = true, + useHybridFlow: Bool = true, + supportWelcomeDiscovery: Bool = false + ) { + let useStaticConfiguration = dynamicAppConfigName == nil + let userAppConfigName = useStaticConfiguration ? staticAppConfigName : dynamicAppConfigName! + let userScopeSelection = useStaticConfiguration ? staticScopeSelection : dynamicScopeSelection + + // Launch + launch() + + // Login + login( + user: user, + staticAppConfigName: staticAppConfigName, + staticScopeSelection: staticScopeSelection, + dynamicAppConfigName: dynamicAppConfigName, + dynamicScopeSelection: dynamicScopeSelection, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow, + supportWelcomeDiscovery: supportWelcomeDiscovery + ) + + // Validate + validate( + user: user, + staticAppConfigName: staticAppConfigName, + staticScopeSelection: staticScopeSelection, + userAppConfigName: userAppConfigName, + userScopeSelection: userScopeSelection, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow + ) + } + + /// Logs in an additional user (multi-user scenario) and validates the credentials. + /// + /// Use this method after an initial user is already logged in to add another user account. + /// Taps the "Add User" button before performing login. + /// + /// - Parameters: + /// - user: The user to log in with. + /// - staticAppConfigName: The static app configuration name. + /// - staticScopeSelection: The scope selection for static configuration. Defaults to `.empty`. + /// - dynamicAppConfigName: Optional dynamic app configuration name (provided at runtime). Is used when provided. + /// - dynamicScopeSelection: The scope selection for dynamic configuration. Defaults to `.empty`. + /// - useWebServerFlow: Whether to use web server OAuth flow. Defaults to `true`. + /// - useHybridFlow: Whether to use hybrid authentication flow. Defaults to `true`. + /// - supportWelcomeDiscovery: Whether to support welcome/discovery screen. Defaults to `false`. + func loginOtherUserAndValidate( + user: KnownUserConfig, + staticAppConfigName: KnownAppConfig, + staticScopeSelection: ScopeSelection = .empty, + dynamicAppConfigName: KnownAppConfig? = nil, + dynamicScopeSelection: ScopeSelection = .empty, + useWebServerFlow: Bool = true, + useHybridFlow: Bool = true, + supportWelcomeDiscovery: Bool = false + ) { + let useStaticConfiguration = dynamicAppConfigName == nil + let userAppConfigName = useStaticConfiguration ? staticAppConfigName : dynamicAppConfigName! + let userScopeSelection = useStaticConfiguration ? staticScopeSelection : dynamicScopeSelection + + // Switch to add new user + mainPage.performAddUser() + + // Login + login( + user: user, + staticAppConfigName: staticAppConfigName, + staticScopeSelection: staticScopeSelection, + dynamicAppConfigName: dynamicAppConfigName, + dynamicScopeSelection: dynamicScopeSelection, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow, + supportWelcomeDiscovery: supportWelcomeDiscovery + ) + + // Validate + validate( + user: user, + staticAppConfigName: staticAppConfigName, + staticScopeSelection: staticScopeSelection, + userAppConfigName: userAppConfigName, + userScopeSelection: userScopeSelection, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow + ) + } + + /// Restarts the app and validates that the user session persists. + /// + /// Terminates and relaunches the app, then validates that the user is still logged in + /// with the expected credentials. Use this to test session persistence. + /// + /// - Parameters: + /// - user: The user that should still be logged in after restart. Defaults to `.first`. + /// - userAppConfigName: The app configuration the user was logged in with. + /// - userScopeSelection: The scope selection the user was logged in with. Defaults to `.empty`. + /// - useWebServerFlow: Whether web server OAuth flow was used. Defaults to `true`. + /// - useHybridFlow: Whether hybrid authentication flow was used. Defaults to `true`. + func restartAndValidate( + user: KnownUserConfig = .first, + userAppConfigName: KnownAppConfig, + userScopeSelection: ScopeSelection = .empty, + useWebServerFlow: Bool = true, + useHybridFlow: Bool = true + ) { + // Restart + app.terminate() + app.launch() + + // Validate user + // Not checking static app config since it will depend on the bootconfig of the target app + validateUser( + user: user, + userAppConfigName: userAppConfigName, + userScopeSelection: userScopeSelection, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow + ) + } + + /// Migrates the refresh token to a new app configuration and validates the result. + /// + /// Performs a refresh token migration from the current app configuration to a new one, + /// then validates that the credentials are updated correctly and the refresh token has changed. + /// + /// - Parameters: + /// - staticAppConfigName: The static app configuration name. + /// - 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`. + func migrateAndValidate( + staticAppConfigName: KnownAppConfig, + staticScopeSelection: ScopeSelection = .empty, + migrationAppConfigName: KnownAppConfig, + migrationScopeSelection: ScopeSelection = .empty, + useWebServerFlow: Bool = true, + useHybridFlow: Bool = true + ) { + // Get original credentials before migration + let originalUserCredentials = mainPage.getUserCredentials() + + // Get current user + let user = getKnownUserConfig(byUsername: originalUserCredentials.username) + + + // Migrate refresh token + migrateRefreshToken( + appConfigName: migrationAppConfigName, + scopeSelection: migrationScopeSelection + ) + + // Validate after migration + let migratedUserCredentials = validate( + user: user, + staticAppConfigName: staticAppConfigName, + staticScopeSelection: staticScopeSelection, + userAppConfigName: migrationAppConfigName, + userScopeSelection: migrationScopeSelection, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow + ) + + // Making sure the refresh token changed + XCTAssertNotEqual( + originalUserCredentials.refreshToken, + migratedUserCredentials.refreshToken, + "Refresh token should have been migrated" + ) + } + + // MARK: - Private Helpers + + @discardableResult + private func validateUser( + user: KnownUserConfig, + userAppConfigName: KnownAppConfig, + userScopeSelection: ScopeSelection, + useWebServerFlow: Bool, + useHybridFlow: Bool, + ) -> UserCredentialsData { + + let userConfig = getUser(user) + let userAppConfig = getAppConfig(named: userAppConfigName) + let expectedGrantedScopes = testConfig.getExpectedScopesGranted(for: userAppConfig, userScopeSelection) + let issuesJwt = userAppConfig.issuesJwt + + // Check that app loads and shows the expected user credentials etc + assertMainPageLoaded() + + + + // Check the user credentials (consumer key should match the app config used) + let userCredentials = checkUserCredentials( + username: userConfig.username, + userConsumerKey: userAppConfig.consumerKey, + userRedirectUri: userAppConfig.redirectUri, + grantedScopes: expectedGrantedScopes, + issuesJwt: issuesJwt + ) + + // Check JWT if applicable + checkJwtDetailsIfApplicable( + appConfig: userAppConfig, + scopes: expectedGrantedScopes, + beaconChildConsumerKey: userCredentials.beaconChildConsumerKey + ) + + // Additional login-specific validations + assertSIDs(userCredentialsData: userCredentials, useHybridFlow: useHybridFlow, useJwt: issuesJwt) + assertURLs(userCredentialsData: userCredentials, useWebServerFlow: useWebServerFlow) + + // Revoke and refresh cycle + assertRevokeAndRefreshWorks(previousCredentials: userCredentials) + + return userCredentials + } + + @discardableResult + private func validate( + user: KnownUserConfig, + staticAppConfigName: KnownAppConfig, + staticScopeSelection: ScopeSelection, + userAppConfigName: KnownAppConfig, + userScopeSelection: ScopeSelection, + useWebServerFlow: Bool, + useHybridFlow: Bool, + ) -> UserCredentialsData { + + let staticAppConfig = getAppConfig(named: staticAppConfigName) + + // Check that app loads and shows the expected user credentials etc + assertMainPageLoaded() + + let userCredentials = validateUser( + user: user, + userAppConfigName: userAppConfigName, + userScopeSelection: userScopeSelection, + useWebServerFlow: useWebServerFlow, + useHybridFlow: useHybridFlow + ) + + // Check the oauth configuration + _ = checkOauthConfiguration( + staticConsumerKey: staticAppConfig.consumerKey, + staticCallbackUrl: staticAppConfig.redirectUri, + staticScopes: testConfig.getScopesToRequest(for: staticAppConfig, staticScopeSelection) + ) + + return userCredentials + } + + private func migrateRefreshToken( + appConfigName: KnownAppConfig, + scopeSelection: ScopeSelection + ) { + let appConfig = getAppConfig(named: appConfigName) + let scopesToRequest = testConfig.getScopesToRequest(for: appConfig, scopeSelection) + + XCTAssert(mainPage.changeAppConfig(appConfig: appConfig, scopesToRequest: scopesToRequest), "Failed to migrate refresh token") + } + + private func getUserCredentials() -> UserCredentialsData { + return mainPage.getUserCredentials() + } + + private func assertMainPageLoaded() { + XCTAssert(mainPage.isShowing(), "AuthFlowTester is not loaded") + } + + private func checkUserCredentials(username: String, userConsumerKey: String, userRedirectUri: String, grantedScopes: String, issuesJwt: Bool) -> UserCredentialsData { + let userCredentials = mainPage.getUserCredentials() + XCTAssertEqual(userCredentials.username, username, "Username in credentials should match expected username") + XCTAssertEqual(userCredentials.clientId, userConsumerKey, "Client ID in credentials should match expected consumer key") + XCTAssertEqual(userCredentials.redirectUri, userRedirectUri, "Redirect URI in credentials should match expected redirect URI") + XCTAssertEqual(userCredentials.credentialsScopes, grantedScopes, "Scopes in credentials should match expected granted scopes") + XCTAssertEqual(userCredentials.tokenFormat, issuesJwt ? "jwt" : "", "Not the expected token format") + return userCredentials + } + + private func checkOauthConfiguration(staticConsumerKey: String, staticCallbackUrl: String, staticScopes: String) -> OAuthConfigurationData { + let oauthConfiguration = mainPage.getOAuthConfiguration() + XCTAssertEqual(oauthConfiguration.configuredConsumerKey, staticConsumerKey, "Configured consumer key should match expected value") + XCTAssertEqual(oauthConfiguration.configuredCallbackUrl, staticCallbackUrl, "Configured callback URL should match expected value") + XCTAssertEqual(oauthConfiguration.configuredScopes, staticScopes == "" ? "(none)" : staticScopes, "Configured scopes should match requested scopes") + return oauthConfiguration + } + + private func checkJwtDetails(clientId: String, scopes: String) -> JwtDetailsData? { + guard let jwtDetails = mainPage.getJwtDetails() else { + XCTFail("No JWT details found") + return nil + } + XCTAssertEqual(jwtDetails.clientId, clientId, "JWT client ID should match expected consumer key") + XCTAssertEqual(sortedScopes(jwtDetails.scopes), scopes, "JWT scopes should match expected scopes") + return jwtDetails + } + + private func assertSIDs(userCredentialsData: UserCredentialsData, useHybridFlow: Bool, useJwt: Bool) { + let hasContentScope = userCredentialsData.credentialsScopes.contains("content") + let hasLightningScope = userCredentialsData.credentialsScopes.contains("lightning") + let hasVisualforceScope = userCredentialsData.credentialsScopes.contains("visualforce") + + assertNotEmpty(userCredentialsData.contentDomain, shouldNotBeEmpty: hasContentScope && useHybridFlow, "Content domain") + assertNotEmpty(userCredentialsData.contentSid, shouldNotBeEmpty: hasContentScope && useHybridFlow, "Content SID") + + assertNotEmpty(userCredentialsData.lightningDomain, shouldNotBeEmpty: hasLightningScope && useHybridFlow, "Lightning domain") + assertNotEmpty(userCredentialsData.lightningSid, shouldNotBeEmpty: hasLightningScope && useHybridFlow, "Lightning SID") + + assertNotEmpty(userCredentialsData.vfDomain, shouldNotBeEmpty: hasVisualforceScope && useHybridFlow, "VF domain") + assertNotEmpty(userCredentialsData.vfSid, shouldNotBeEmpty: hasVisualforceScope && useHybridFlow, "VF SID") + + assertNotEmpty(userCredentialsData.parentSid, shouldNotBeEmpty: useJwt && useHybridFlow, "Parent SID") + } + + private func assertURLs(userCredentialsData: UserCredentialsData, useWebServerFlow: Bool) { + let hasApiScope = userCredentialsData.credentialsScopes.contains("api") + let hasSfapApiScope = userCredentialsData.credentialsScopes.contains("sfap_api") + + assertNotEmpty(userCredentialsData.instanceUrl, shouldNotBeEmpty: true, "Instance URL") + XCTAssertTrue(userCredentialsData.identityUrl.hasSuffix(userCredentialsData.organizationId + "/" + userCredentialsData.userId), "Identity URL should end with orgId/userId") + assertNotEmpty(userCredentialsData.apiUrl, shouldNotBeEmpty: hasApiScope, "API URL") + assertNotEmpty(userCredentialsData.apiInstanceUrl, shouldNotBeEmpty: hasSfapApiScope && useWebServerFlow /* not returned with user agent flow */, "API Instance URL") + } + + private func assertNotEmpty(_ value: String, shouldNotBeEmpty: Bool, _ name: String) { + if shouldNotBeEmpty { + XCTAssertNotEqual(value, "", "\(name) should not be empty") + } else { + XCTAssertEqual(value, "", "\(name) should be empty") + } + } + + private func assertRestRequestWorks() { + XCTAssert(mainPage.makeRestRequest(), "Failed to make REST request") + } + + private func assertRevokeWorks() { + XCTAssert(mainPage.revokeAccessToken(), "Failed to revoke access token") + } + + private func getAppConfig(named name: KnownAppConfig) -> AppConfig { + do { + return try testConfig.getApp(named: name) + } catch { + XCTFail("Failed to get app config for \(name): \(error)") + fatalError("Failed to get app config for \(name): \(error)") + } + } + + private func getUser(_ user: KnownUserConfig) -> UserConfig { + do { + return try testConfig.getUser(user) + } catch { + XCTFail("Failed to get user \(user): \(error)") + fatalError("Failed to get user \(user): \(error)") + } + } + + private func getKnownUserConfig(byUsername username: String) -> KnownUserConfig { + do { + return try testConfig.getKnownUserConfig(byUsername: username) + } catch { + XCTFail("Failed to get user \(username): \(error)") + fatalError("Failed to get user \(username): \(error)") + } + } + + private func checkJwtDetailsIfApplicable(appConfig: AppConfig, scopes: String, beaconChildConsumerKey: String = "") { + if appConfig.issuesJwt { + _ = checkJwtDetails( + clientId: beaconChildConsumerKey == "" ? appConfig.consumerKey : beaconChildConsumerKey, + scopes: scopes + ) + } + } + + private func assertRevokeAndRefreshWorks(previousCredentials: UserCredentialsData) { + // Revoke access token + assertRevokeWorks() + + // Make REST request (which should trigger token refresh) + assertRestRequestWorks() + + let credentialsAfterRefresh = getUserCredentials() + + // Assert access token changed + XCTAssertNotEqual( + previousCredentials.accessToken, + credentialsAfterRefresh.accessToken, + "Access token should have been refreshed" + ) + } + + private func sortedScopes(_ value: String) -> String { + let scopes = value + .split(separator: " ") + .map { String($0) } + .filter { !$0.isEmpty } + .sorted() + return scopes.joined(separator: " ") + } +} diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/BeaconLoginTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/BeaconLoginTests.swift new file mode 100644 index 0000000000..19e0742635 --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/BeaconLoginTests.swift @@ -0,0 +1,98 @@ +/* + BeaconLoginTests.swift + AuthFlowTesterUITests + + Copyright (c) 2025-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 login flows using Beacon app configurations. +/// Beacon apps are lightweight authentication apps for specific use cases. +/// +/// NB: Tests use the first user from test_config.json +/// +class BeaconLoginTests: BaseAuthFlowTesterTest { + + // MARK: - Beacon Opaque Tests + + /// Login with Beacon advanced opaque using default scopes and web server flow. + func testBeaconAdvancedOpaque_DefaultScopes() throws { + launchLoginAndValidate(staticAppConfigName: .beaconAdvancedOpaque) + } + + /// Login with Beacon advanced opaque using subset of scopes and web server flow. + func testBeaconAdvancedOpaque_SubsetScopes() throws { + launchLoginAndValidate(staticAppConfigName: .beaconAdvancedOpaque, staticScopeSelection: .subset) + } + + /// Login with Beacon advanced opaque using all scopes and web server flow. + func testBeaconAdvancedOpaque_AllScopes() throws { + launchLoginAndValidate(staticAppConfigName: .beaconAdvancedOpaque, staticScopeSelection: .all) + } + + // MARK: - Beacon JWT Tests + + /// Login with Beacon advanced JWT using default scopes and web server flow. + func testBeaconAdvancedJwt_DefaultScopes() throws { + launchLoginAndValidate(staticAppConfigName: .beaconAdvancedJwt) + } + + /// Login with Beacon advanced JWT using subset of scopes and web server flow. + func testBeaconAdvancedJwt_SubsetScopes() throws { + launchLoginAndValidate(staticAppConfigName: .beaconAdvancedJwt, staticScopeSelection: .subset) + } + + /// Login with Beacon advanced JWT using all scopes and web server flow. + func testBeaconAdvancedJwt_AllScopes() throws { + launchLoginAndValidate(staticAppConfigName: .beaconAdvancedJwt, staticScopeSelection: .all) + } + + // MARK: - Using dynamic config + + /// Login with Beacon advanced JWT using default scopes and web server flow provided as dynamic configuration. + /// Restart the application and validate it still works afterwards + func testBeaconAdvancedJwt_DefaultScopes_DynamicConfiguration_WithRestart() throws { + launchLoginAndValidate( + staticAppConfigName: .beaconAdvancedOpaque, + dynamicAppConfigName: .beaconAdvancedJwt + ) + restartAndValidate( + userAppConfigName: .beaconAdvancedJwt + ) + } + + /// Login with Beacon advanced JWT using subset of scopes and web server flow provided as dynamic configuration. + /// Restart the application and validate it still works afterwards + func testBeaconAdvancedJwt_SubsetScopes_DynamicConfiguration_WithRestart() throws { + launchLoginAndValidate( + staticAppConfigName: .beaconAdvancedOpaque, + dynamicAppConfigName: .beaconAdvancedJwt, + dynamicScopeSelection: .subset + ) + restartAndValidate( + userAppConfigName: .beaconAdvancedJwt, + userScopeSelection: .subset + ) + } +} diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/ECALoginTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/ECALoginTests.swift new file mode 100644 index 0000000000..11376936bc --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/ECALoginTests.swift @@ -0,0 +1,97 @@ +/* + ECALoginTests.swift + AuthFlowTesterUITests + + Copyright (c) 2025-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 login flows using External Client App (ECA) configurations. +/// ECA apps are first-party Salesforce apps that use enhanced authentication flows. +/// +/// NB: Tests use the first user from test_config.json +/// +class ECALoginTests: BaseAuthFlowTesterTest { + + // MARK: - ECA Opaque Tests + + /// Login with ECA advanced opaque using default scopes and web server flow. + func testECAAdvancedOpaque_DefaultScopes() throws { + launchLoginAndValidate(staticAppConfigName: .ecaAdvancedOpaque) + } + + /// Login with ECA advanced opaque using subset of scopes and web server flow. + func testECAAdvancedOpaque_SubsetScopes() throws { + launchLoginAndValidate(staticAppConfigName: .ecaAdvancedOpaque, staticScopeSelection: .subset) + } + + /// Login with ECA advanced opaque using all scopes and web server flow. + func testECAAdvancedOpaque_AllScopes() throws { + launchLoginAndValidate(staticAppConfigName: .ecaAdvancedOpaque, staticScopeSelection: .all) + } + + // MARK: - ECA JWT Tests + + /// Login with ECA advanced JWT using default scopes and web server flow. + func testECAAdvancedJwt_DefaultScopes() throws { + launchLoginAndValidate(staticAppConfigName: .ecaAdvancedJwt) + } + + /// Login with ECA advanced JWT using subset of scopes and web server flow. + func testECAAdvancedJwt_SubsetScopes_NotHybrid() throws { + launchLoginAndValidate(staticAppConfigName: .ecaAdvancedJwt, staticScopeSelection: .subset) + } + + /// Login with ECA advanced JWT using all scopes and web server flow. + func testECAAdvancedJwt_AllScopes() throws { + launchLoginAndValidate(staticAppConfigName: .ecaAdvancedJwt, staticScopeSelection: .all) + } + + // MARK: - Using dynamic config + + /// Login with ECA advanced JWT using default scopes and web server flow provided as dynamic configuration. + /// Restart the application and validate it still works afterwards + func testECAAdvancedJwt_DefaultScopes_DynamicConfiguration_WithRestart() throws { + launchLoginAndValidate( + staticAppConfigName: .ecaAdvancedOpaque, + dynamicAppConfigName: .ecaAdvancedJwt + ) + restartAndValidate( + userAppConfigName: .ecaAdvancedJwt + ) + } + + /// Login with ECA advanced JWT using subset of scopes and web server flow provided as dynamic configuration. + /// Restart the application and validate it still works afterwards + func testECAAdvancedJwt_SubsetScopes_DynamicConfiguration_WithRestart() throws { + launchLoginAndValidate( + staticAppConfigName: .ecaAdvancedOpaque, + dynamicAppConfigName: .ecaAdvancedJwt, + dynamicScopeSelection: .subset) + restartAndValidate( + userAppConfigName: .ecaAdvancedJwt, + userScopeSelection: .subset + ) + } +} diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LegacyLoginTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LegacyLoginTests.swift new file mode 100644 index 0000000000..bd0011206b --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LegacyLoginTests.swift @@ -0,0 +1,135 @@ +/* + LegacyLoginTests.swift + AuthFlowTesterUITests + + Copyright (c) 2025-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 including: +/// - Connected App (CA) configurations (traditional OAuth connected apps) +/// - User agent flow tests +/// - Non-hybrid flow tests +/// +/// NB: Tests use the first user from test_config.json +/// +class LegacyLoginTests: BaseAuthFlowTesterTest { + + // MARK: - CA Web Server Flow Tests + + /// Login with CA advanced opaque using default scopes and web server flow. + func testCAAdvancedOpaque_DefaultScopes_WebServerFlow() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque) + } + + /// Login with CA advanced opaque using subset of scopes and web server flow. + func testCAAdvancedOpaque_SubsetScopes_WebServerFlow() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, staticScopeSelection: .subset, useHybridFlow: false) + } + + /// Login with CA advanced opaque using all scopes and web server flow. + func testCAAdvancedOpaque_AllScopes_WebServerFlow() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, staticScopeSelection: .all) + } + + // MARK: - CA Non-hybrid Web Server Flow Tests + + /// Login with CA advanced opaque using default scopes and (non-hybrid) web server flow. + func testCAAdvancedOpaque_DefaultScopes_WebServerFlow_NotHybrid() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, useHybridFlow: false) + } + + /// Login with CA advanced opaque using subset of scopes and (non-hybrid) web server flow. + func testCAAdvancedOpaque_SubsetScopes_WebServerFlow_NotHybrid() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, staticScopeSelection: .subset, useHybridFlow: false) + } + + /// Login with CA advanced opaque using all scopes and (non-hybrid) web server flow. + func testCAAdvancedOpaque_AllScopes_WebServerFlow_NotHybrid() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, staticScopeSelection: .all, useHybridFlow: false) + } + + // MARK: - CA User Agent Flow Tests + + /// Login with CA advanced opaque using default scopes and user agent flow. + func testCAAdvancedOpaque_DefaultScopes_UserAgentFlow() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, useWebServerFlow: false) + } + + /// Login with CA advanced opaque using subset of scopes and user agent flow. + func testCAAdvancedOpaque_SubsetScopes_UserAgentFlow() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, staticScopeSelection: .subset, useWebServerFlow: false) + } + + /// Login with CA advanced opaque using all scopes and user agent flow. + func testCAAdvancedOpaque_AllScopes_UserAgentFlow() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, staticScopeSelection: .all, useWebServerFlow: false) + } + + // MARK: - CA Non-hybrid User Agent Flow Tests + + /// Login with CA advanced opaque using default scopes and (non-hybrid) user agent flow. + func testCAAdvancedOpaque_DefaultScopes_UserAgentFlow_NotHybrid() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, useWebServerFlow: false, useHybridFlow: false) + } + + /// Login with CA advanced opaque using subset of scopes and (non-hybrid) user agent flow. + func testCAAdvancedOpaque_SubsetScopes_UserAgentFlow_NotHybrid() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, staticScopeSelection: .subset, useWebServerFlow: false, useHybridFlow: false) + } + + /// Login with CA advanced opaque using all scopes and (non-hybrid) user agent flow. + func testCAAdvancedOpaque_AllScopes_UserAgentFlow_NotHybrid() throws { + launchLoginAndValidate(staticAppConfigName: .caAdvancedOpaque, staticScopeSelection: .all, useWebServerFlow: false, useHybridFlow: false) + } + + // MARK: - Using dynamic config + + /// Login with CA advanced JWT using default scopes and web server flow provided as dynamic configuration. + /// Restart the application and validate it still works afterwards + func testCAAdvancedJwt_DefaultScopes_DynamicConfiguration_WithRestart() throws { + launchLoginAndValidate( + staticAppConfigName: .caAdvancedOpaque, + dynamicAppConfigName: .caAdvancedJwt + ) + restartAndValidate( + userAppConfigName: .caAdvancedJwt + ) + } + + /// Login with CA advanced JWT using subset of scopes and web server flow provided as dynamic configuration. + /// Restart the application and validate it still works afterwards + func testCAAdvancedJwt_SubsetScopes_DynamicConfiguration_WithRestart() throws { + launchLoginAndValidate( + staticAppConfigName: .caAdvancedOpaque, + dynamicAppConfigName: .caAdvancedJwt, + dynamicScopeSelection: .subset) + restartAndValidate( + userAppConfigName: .caAdvancedJwt, + userScopeSelection: .subset + ) + } + +} + diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MigrationTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MigrationTests.swift new file mode 100644 index 0000000000..578ac05df4 --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MigrationTests.swift @@ -0,0 +1,161 @@ +/* + MigrationTests.swift + AuthFlowTesterUITests + + Copyright (c) 2025-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 migrating refresh tokens between different app configurations. +/// These tests verify that users can seamlessly transition between app types +/// (CA, ECA, Beacon) and token formats (opaque, JWT) without re-authentication. +/// +/// NB: Tests use the second user from test_config.json +/// +class MigrationTests: BaseAuthFlowTesterTest { + + // MARK: - Migration within same app (scope upgrade) + + /// Migrate within same CA (scope upgrade). + func testMigrateCA_AddMoreScopes() throws { + launchAndLogin( + user:.second, + staticAppConfigName: .caAdvancedJwt, + staticScopeSelection: .subset + ) + migrateAndValidate( + staticAppConfigName: .caAdvancedJwt, + staticScopeSelection: .subset, + migrationAppConfigName: .caAdvancedJwt, + migrationScopeSelection: .all + ) + } + + /// Migrate within same ECA (scope upgrade). + func testMigrateECA_AddMoreScopes() throws { + launchAndLogin( + user:.second, + staticAppConfigName: .ecaAdvancedJwt, + staticScopeSelection: .subset + ) + migrateAndValidate( + staticAppConfigName: .ecaAdvancedJwt, + staticScopeSelection: .subset, + migrationAppConfigName: .ecaAdvancedJwt, + migrationScopeSelection: .all + ) + } + + /// Migrate within same Beacon (scope upgrade). + func testMigrateBeacon_AddMoreScopes() throws { + launchAndLogin( + user:.second, + staticAppConfigName: .beaconAdvancedJwt, + staticScopeSelection: .subset + ) + migrateAndValidate( + staticAppConfigName: .beaconAdvancedJwt, + staticScopeSelection: .subset, + migrationAppConfigName: .beaconAdvancedJwt, + migrationScopeSelection: .all + ) + } + + // MARK: - Migration to or from beacon + + // Migrate from CA to Beacon + func testMigrateCAToBeacon() throws { + launchAndLogin( + user:.second, + staticAppConfigName: .caAdvancedOpaque + ) + migrateAndValidate( + staticAppConfigName: .caAdvancedOpaque, + migrationAppConfigName: .beaconAdvancedOpaque + ) + } + + // Migrate from Beacon to CA + func testMigrateBeaconToCA() throws { + launchAndLogin( + user:.second, + staticAppConfigName: .beaconAdvancedOpaque + ) + migrateAndValidate( + staticAppConfigName: .beaconAdvancedOpaque, + migrationAppConfigName: .caAdvancedOpaque + ) + } + + // MARK: - Cross-App Migrations with rollbacks + + /// Migrate from CA to ECA and back to CA + func testMigrateCAToECA() throws { + launchAndLogin( + user:.second, + staticAppConfigName: .caAdvancedOpaque + ) + migrateAndValidate( + staticAppConfigName: .caAdvancedOpaque, + migrationAppConfigName: .ecaAdvancedOpaque + ) + migrateAndValidate( + staticAppConfigName: .caAdvancedOpaque, // should not have changed + migrationAppConfigName: .caAdvancedOpaque + ) + } + + // Migrate from CA to Beacon and back to CA + func testMigrateCAToBeaconAndBack() throws { + launchAndLogin( + user:.second, + staticAppConfigName: .caAdvancedOpaque + ) + migrateAndValidate( + staticAppConfigName: .caAdvancedOpaque, + migrationAppConfigName: .beaconAdvancedOpaque + ) + migrateAndValidate( + staticAppConfigName: .caAdvancedOpaque, // should not have changed + migrationAppConfigName: .caAdvancedOpaque + ) + } + + /// Migrate from Beacon opaque to Beacon JWT and back to Beacon opaque + func testMigrateBeaconOpaqueToJWTAndBack() throws { + launchAndLogin( + user:.second, + staticAppConfigName: .beaconAdvancedOpaque + ) + migrateAndValidate( + staticAppConfigName: .beaconAdvancedOpaque, + migrationAppConfigName: .beaconAdvancedJwt + ) + migrateAndValidate( + staticAppConfigName: .beaconAdvancedOpaque, // should not have changed + migrationAppConfigName: .beaconAdvancedOpaque + ) + } +} + diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift new file mode 100644 index 0000000000..137bfc8f18 --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/MultiUserLoginTests.swift @@ -0,0 +1,242 @@ +/* + MultiUserLoginTests.swift + AuthFlowTesterUITests + + Copyright (c) 2025-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 multi-user login scenarios. +/// Tests login with two users using various configurations: +/// - Static vs dynamic app configuration +/// - Same or different app types (opaque vs JWT) +/// - Same or different scopes +/// +/// NB: Tests use the fourth and fifth user from test_config.json +/// +class MultiUserLoginTests: BaseAuthFlowTesterTest { + + // MARK: - Both Users Static Config + + /// Both users use static config, same app type (opaque), same scopes (default). + func testBothStatic_SameApp_SameScopes() throws { + // Initial user + launchAndLogin( + user: .fourth, + staticAppConfigName: .ecaAdvancedOpaque + ) + + // Other user + loginOtherUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaAdvancedOpaque + ) + + // Switch back to initial user + switchToUserAndValidate( + user: .fourth, + staticAppConfigName: .ecaAdvancedOpaque, + userAppConfigName: .ecaAdvancedOpaque) + + // Switch back to other user + switchToUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaAdvancedOpaque, + userAppConfigName: .ecaAdvancedOpaque) + + // Logout second user + logout() + } + + /// Both users use static config, different app types (opaque + jwt), same scopes (default). + func testBothStatic_DifferentApps() throws { + // Initial user + launchAndLogin( + user: .fourth, + staticAppConfigName: .ecaAdvancedOpaque + ) + + // Other user + loginOtherUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaAdvancedJwt + ) + + // Switch back to initial user + switchToUserAndValidate( + user: .fourth, + staticAppConfigName: .ecaAdvancedJwt, // static config overwritten + userAppConfigName: .ecaAdvancedOpaque) + + // Switch back to other user + switchToUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaAdvancedJwt, + userAppConfigName: .ecaAdvancedJwt) + + // Logout second user + logout() + } + + /// Both users use static config, same app type, different scopes (first subset, second default). + func testBothStatic_SameApp_DifferentScopes() throws { + // Initial user + launchAndLogin( + user: .fourth, + staticAppConfigName: .ecaAdvancedOpaque, + staticScopeSelection: .subset + ) + + // Other user + loginOtherUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaAdvancedOpaque + ) + + // Switch back to initial user + switchToUserAndValidate( + user: .fourth, + staticAppConfigName: .ecaAdvancedOpaque, + staticScopeSelection: .empty, + userAppConfigName: .ecaAdvancedOpaque, + userScopeSelection: .subset + ) + + // Switch back to other user + switchToUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaAdvancedOpaque, + staticScopeSelection: .empty, + userAppConfigName: .ecaAdvancedOpaque, + userScopeSelection: .empty + ) + + // Logout second user + logout() + } + + // MARK: - Mixed Static/Dynamic Config + + /// First user static config, second user dynamic config, different apps, same scopes (default). + func testFirstStatic_SecondDynamic_DifferentApps() throws { + // Initial user + launchAndLogin( + user: .fourth, + staticAppConfigName: .ecaAdvancedOpaque + ) + + // Other user + loginOtherUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaAdvancedOpaque, + dynamicAppConfigName: .ecaAdvancedJwt + ) + + // Switch back to initial user + switchToUserAndValidate( + user: .fourth, + staticAppConfigName: .ecaAdvancedOpaque, + userAppConfigName: .ecaAdvancedOpaque + ) + + // Switch back to other user + switchToUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaAdvancedOpaque, + userAppConfigName: .ecaAdvancedJwt, + ) + + // Logout second user + logout() + } + + /// First user dynamic config, second user static config, different apps, same scopes (default). + func testFirstDynamic_SecondStatic_DifferentApps() throws { + // Initial user + launchAndLogin( + user: .fourth, + staticAppConfigName: .ecaBasicOpaque, + dynamicAppConfigName: .ecaAdvancedJwt + ) + + // Other user + loginOtherUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaAdvancedOpaque + ) + + // Switch back to initial user + switchToUserAndValidate( + user: .fourth, + staticAppConfigName: .ecaAdvancedOpaque, + userAppConfigName: .ecaAdvancedJwt + ) + + // Switch back to other user + switchToUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaAdvancedOpaque, + userAppConfigName: .ecaAdvancedOpaque, + ) + + // Logout second user + logout() + } + + // MARK: - Both Users Dynamic Config + + /// Both users use dynamic config, different apps, same scopes (default). + func testBothDynamic_DifferentApps() throws { + // Initial user + launchAndLogin( + user: .fourth, + staticAppConfigName: .ecaBasicOpaque, + dynamicAppConfigName: .ecaAdvancedOpaque + ) + + // Other user + loginOtherUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaBasicOpaque, + dynamicAppConfigName: .ecaAdvancedJwt + ) + + // Switch back to initial user + switchToUserAndValidate( + user: .fourth, + staticAppConfigName: .ecaBasicOpaque, + userAppConfigName: .ecaAdvancedOpaque + ) + + // Switch back to other user + switchToUserAndValidate( + user: .fifth, + staticAppConfigName: .ecaBasicOpaque, + userAppConfigName: .ecaAdvancedJwt, + ) + + // Logout second user + logout() + } +} diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/TestConfigUtils.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/TestConfigUtils.swift new file mode 100644 index 0000000000..1d0329987d --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/TestConfigUtils.swift @@ -0,0 +1,310 @@ +/* + TestConfigUtils.swift + AuthFlowTesterUITests + + Copyright (c) 2025-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 + +// MARK: - Errors + +enum TestConfigError: Error, CustomStringConvertible { + case noPrimaryUser + case noSecondaryUser + case noThirdUser + case noFourthUser + case noFifthUser + case userNotFound(String) + case appNotFound(String) + case appNotConfigured(String) + + var description: String { + switch self { + case .noPrimaryUser: + return "No primary user found in test_config.json" + case .noSecondaryUser: + return "No secondary user found in test_config.json" + case .noThirdUser: + return "No third user found in test_config.json" + case .noFourthUser: + return "No fourth user found in test_config.json" + case .noFifthUser: + return "No fifth user found in test_config.json" + case .userNotFound(let username): + return "User '\(username)' not found in test_config.json" + case .appNotFound(let appName): + return "App '\(appName)' not found in test_config.json" + case .appNotConfigured(let appName): + return "App '\(appName)' has empty consumerKey in test_config.json" + } + } +} + +// MAKR: - 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 test_config.json + case subset // will send a subset of the scopes defined in test_config.json +} + +// MARK: - Configured Users + +enum KnownUserConfig { + case first + case second + case third + case fourth + case fifth +} + +// MARK: - App Names + +enum KnownAppConfig: String { + case ecaBasicOpaque = "eca_basic_opaque" + case ecaBasicJwt = "eca_basic_jwt" + case ecaAdvancedOpaque = "eca_advanced_opaque" + case ecaAdvancedJwt = "eca_advanced_jwt" + case beaconBasicOpaque = "beacon_basic_opaque" + case beaconBasicJwt = "beacon_basic_jwt" + case beaconAdvancedOpaque = "beacon_advanced_opaque" + case beaconAdvancedJwt = "beacon_advanced_jwt" + case caBasicOpaque = "ca_basic_opaque" + case caBasicJwt = "ca_basic_jwt" + case caAdvancedOpaque = "ca_advanced_opaque" + case caAdvancedJwt = "ca_advanced_jwt" +} + +// MARK: - Configuration Models + +/// Represents an app configuration for testing +struct AppConfig: Codable { + let name: String + let consumerKey: String + let redirectUri: String + let scopes: String + + /// Returns true if the app issues JWT tokens (name contains "_jwt") + var issuesJwt: Bool { + return name.contains("_jwt") + } + + /// Returns scopes as an array + var scopesArray: [String] { + return scopes.split(separator: " ").map { String($0) }.filter { !$0.isEmpty } + } +} + +/// Represents a user configuration for testing +struct UserConfig: Codable { + let username: String + let password: String +} + +/// Represents the complete test configuration +struct TestConfig: Codable { + let loginHost: String + let apps: [AppConfig] + let users: [UserConfig] +} + +// MARK: - Configuration Utility + +/// Utility class to parse and access test configuration from test_config.json in the test bundle +class TestConfigUtils { + + /// Shared singleton instance + static let shared = TestConfigUtils() + + /// Parsed test configuration (nil if not provided or parsing failed) + private(set) var config: TestConfig? + + /// Error encountered during parsing (if any) + private(set) var parseError: Error? + + private init() { + parseConfiguration() + } + + // MARK: - Configuration Parsing + + /// Parses the test configuration from test_config.json file in the test bundle + private func parseConfiguration() { + // Get the bundle for this class + let bundle = Bundle(for: TestConfigUtils.self) + + // Look for test_config.json file + guard let configPath = bundle.path(forResource: "test_config", ofType: "json") else { + print("⚠️ test_config.json file not found in test bundle") + return + } + + // Read the file contents + guard let jsonData = try? Data(contentsOf: URL(fileURLWithPath: configPath)) else { + let error = NSError(domain: "TestConfigUtils", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to read test_config.json file"]) + parseError = error + print("❌ Failed to read test_config.json file") + return + } + + // Parse JSON configuration + do { + let decoder = JSONDecoder() + config = try decoder.decode(TestConfig.self, from: jsonData) + print("✅ Test configuration loaded successfully from test_config.json") + print(" Login Host: \(config?.loginHost ?? "none")") + print(" Apps: \(config?.apps.count ?? 0)") + print(" Users: \(config?.users.count ?? 0)") + } catch { + parseError = error + print("❌ Failed to parse test configuration: \(error.localizedDescription)") + } + } + + // MARK: - Configuration Access + + /// Returns true if configuration was successfully loaded + var hasConfig: Bool { + return config != nil + } + + /// Returns the login host from configuration + var loginHost: String? { + return config?.loginHost + } + + var loginHostNoProtocol: String? { + return loginHost? + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + } + + /// Returns all apps from configuration + var apps: [AppConfig] { + return config?.apps ?? [] + } + + /// Returns all users from configuration + var users: [UserConfig] { + return config?.users ?? [] + } + + // MARK: - Scope Utilities + + /// Removes a scope from a space-separated scope string. + /// + /// - Parameters: + /// - scopes: Space-separated scope string. + /// - scopeToRemove: The scope to remove from the string. + /// - Returns: Space-separated scope string with the specified scope removed. + func removeScope(scopes: String, scopeToRemove: String) -> String { + // Split the scopes string into an array + let scopesArray = scopes.split(separator: " ") + + // Remove the specified scope + let filteredScopes = scopesArray.filter { $0 != scopeToRemove } + + // Join the remaining scopes with space delimiter + return filteredScopes.joined(separator: " ") + } + + // MARK: - Throwing Accessors + + /// Returns a user by their position (first or second) or throws an error if not found + func getUser(_ user: KnownUserConfig) throws -> UserConfig { + switch user { + case .first: + guard let firstUser = config?.users.first else { + throw TestConfigError.noPrimaryUser + } + return firstUser + case .second: + guard let users = config?.users, users.count >= 2 else { + throw TestConfigError.noSecondaryUser + } + return users[1] + case .third: + guard let users = config?.users, users.count >= 3 else { + throw TestConfigError.noThirdUser + } + return users[2] + case .fourth: + guard let users = config?.users, users.count >= 4 else { + throw TestConfigError.noFourthUser + } + return users[3] + case .fifth: + guard let users = config?.users, users.count >= 5 else { + throw TestConfigError.noFifthUser + } + return users[4] + } + } + + /// Returns a known user config by their username or throws an error if not found + func getKnownUserConfig(byUsername username: String) throws -> KnownUserConfig { + guard let users = config?.users, + let index = users.firstIndex(where: { $0.username == username }) else { + throw TestConfigError.userNotFound(username) + } + switch index { + case 0: return .first + case 1: return .second + case 2: return .third + case 3: return .fourth + case 4: return .fifth + default: throw TestConfigError.userNotFound(username) + } + } + + /// Returns an app by its name or throws an error if not found or not configured + func getApp(named name: KnownAppConfig) throws -> AppConfig { + guard let app = config?.apps.first(where: { $0.name == name.rawValue }) else { + throw TestConfigError.appNotFound(name.rawValue) + } + guard !app.consumerKey.isEmpty else { + throw TestConfigError.appNotConfigured(name.rawValue) + } + return app + } + + /// Returns scopes to request + 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 .all: return appConfig.scopes + } + } + + /// Returns expected scopes granted + func getExpectedScopesGranted(for appConfig:AppConfig, _ scopeSelection: ScopeSelection) -> String { + switch(scopeSelection) { + case .empty: return appConfig.scopes // that assumes the scopes in 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 .all: return appConfig.scopes + } + } +} + diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/overview.md b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/overview.md new file mode 100644 index 0000000000..fd3593280a --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/overview.md @@ -0,0 +1,161 @@ +# AuthFlowTester UI Tests Overview + +This document provides an overview of all UI tests in the AuthFlowTester test suite. + +## Test Classes + +| Class | Description | +|-------|-------------| +| `LegacyLoginTests` | Tests for legacy login flows (connected apps, user agent flow, non-hybrid flow) | +| `ECALoginTests` | Tests for External Client App (ECA) login flows | +| `BeaconLoginTests` | Tests for Beacon app login flows | +| `MigrationTests` | Tests for refresh token migration between app configurations | +| `MultiUserLoginTests` | Tests for multi-user login scenarios | + +--- + +## Login Tests + +### LegacyLoginTests (14 tests) + +Tests for Connected App (CA) configurations including user agent flow and non-hybrid flow options. + +| Test Name | App Config | Scopes | Flow | Hybrid | Dynamic Config | +|-----------|------------|--------|------|--------|----------------| +| `testCAAdvancedOpaque_DefaultScopes_WebServerFlow` | CA Advanced Opaque | Default | Web Server | Yes | No | +| `testCAAdvancedOpaque_SubsetScopes_WebServerFlow` | CA Advanced Opaque | Subset | Web Server | No | No | +| `testCAAdvancedOpaque_AllScopes_WebServerFlow` | CA Advanced Opaque | All | Web Server | Yes | No | +| `testCAAdvancedOpaque_DefaultScopes_WebServerFlow_NotHybrid` | CA Advanced Opaque | Default | Web Server | No | No | +| `testCAAdvancedOpaque_SubsetScopes_WebServerFlow_NotHybrid` | CA Advanced Opaque | Subset | Web Server | No | No | +| `testCAAdvancedOpaque_AllScopes_WebServerFlow_NotHybrid` | CA Advanced Opaque | All | Web Server | No | No | +| `testCAAdvancedOpaque_DefaultScopes_UserAgentFlow` | CA Advanced Opaque | Default | User Agent | Yes | No | +| `testCAAdvancedOpaque_SubsetScopes_UserAgentFlow` | CA Advanced Opaque | Subset | User Agent | Yes | No | +| `testCAAdvancedOpaque_AllScopes_UserAgentFlow` | CA Advanced Opaque | All | User Agent | Yes | No | +| `testCAAdvancedOpaque_DefaultScopes_UserAgentFlow_NotHybrid` | CA Advanced Opaque | Default | User Agent | No | No | +| `testCAAdvancedOpaque_SubsetScopes_UserAgentFlow_NotHybrid` | CA Advanced Opaque | Subset | User Agent | No | No | +| `testCAAdvancedOpaque_AllScopes_UserAgentFlow_NotHybrid` | CA Advanced Opaque | All | User Agent | No | No | +| `testCAAdvancedJwt_DefaultScopes_DynamicConfiguration_WithRestart` | CA Advanced JWT | Default | Web Server | Yes | Yes | +| `testCAAdvancedJwt_SubsetScopes_DynamicConfiguration_WithRestart` | CA Advanced JWT | Subset | Web Server | Yes | Yes | + +### ECALoginTests (8 tests) + +Tests for External Client App (ECA) configurations using web server flow with hybrid auth. + +| Test Name | App Config | Scopes | Dynamic Config | +|-----------|------------|--------|----------------| +| `testECAAdvancedOpaque_DefaultScopes` | ECA Advanced Opaque | Default | No | +| `testECAAdvancedOpaque_SubsetScopes` | ECA Advanced Opaque | Subset | No | +| `testECAAdvancedOpaque_AllScopes` | ECA Advanced Opaque | All | No | +| `testECAAdvancedJwt_DefaultScopes` | ECA Advanced JWT | Default | No | +| `testECAAdvancedJwt_SubsetScopes_NotHybrid` | ECA Advanced JWT | Subset | No | +| `testECAAdvancedJwt_AllScopes` | ECA Advanced JWT | All | No | +| `testECAAdvancedJwt_DefaultScopes_DynamicConfiguration_WithRestart` | ECA Advanced JWT | Default | Yes | +| `testECAAdvancedJwt_SubsetScopes_DynamicConfiguration_WithRestart` | ECA Advanced JWT | Subset | Yes | + +### BeaconLoginTests (8 tests) + +Tests for Beacon app configurations using web server flow with hybrid auth. + +| Test Name | App Config | Scopes | Dynamic Config | +|-----------|------------|--------|----------------| +| `testBeaconAdvancedOpaque_DefaultScopes` | Beacon Advanced Opaque | Default | No | +| `testBeaconAdvancedOpaque_SubsetScopes` | Beacon Advanced Opaque | Subset | No | +| `testBeaconAdvancedOpaque_AllScopes` | Beacon Advanced Opaque | All | No | +| `testBeaconAdvancedJwt_DefaultScopes` | Beacon Advanced JWT | Default | No | +| `testBeaconAdvancedJwt_SubsetScopes` | Beacon Advanced JWT | Subset | No | +| `testBeaconAdvancedJwt_AllScopes` | Beacon Advanced JWT | All | No | +| `testBeaconAdvancedJwt_DefaultScopes_DynamicConfiguration_WithRestart` | Beacon Advanced JWT | Default | Yes | +| `testBeaconAdvancedJwt_SubsetScopes_DynamicConfiguration_WithRestart` | Beacon Advanced JWT | Subset | Yes | + +--- + +## Migration Tests + +### MigrationTests (8 tests) + +Tests for migrating refresh tokens between different app configurations without re-authentication. + +| Test Name | Original App | Migration App | Scope Change | +|-----------|--------------|---------------|--------------| +| `testMigrateCA_AddMoreScopes` | CA Advanced JWT (subset) | CA Advanced JWT (all) | Yes (add more scopes) | +| `testMigrateECA_AddMoreScopes` | ECA Advanced JWT (subset) | ECA Advanced JWT (all) | Yes (add more scopes) | +| `testMigrateBeacon_AddMoreScopes` | Beacon Advanced JWT (subset) | Beacon Advanced JWT (all) | Yes (add more scopes) | +| `testMigrateCAToBeacon` | CA Advanced Opaque | Beacon Advanced Opaque | No | +| `testMigrateBeaconToCA` | Beacon Advanced Opaque | CA Advanced Opaque | No | +| `testMigrateCAToECA` | CA Advanced Opaque → ECA Advanced Opaque → CA Advanced Opaque | Migration with rollback | No | +| `testMigrateCAToBeaconAndBack` | CA Advanced Opaque → Beacon Advanced Opaque | Migration to Beacon | No | +| `testMigrateBeaconOpaqueToJWTAndBack` | Beacon Advanced Opaque → Beacon Advanced JWT → Beacon Advanced Opaque | Migration with rollback | No | + +--- + +## Multi-User Tests + +### MultiUserLoginTests (6 tests) + +Tests for login scenarios with two users using various configurations. + +| Test Name | User 1 Config | User 2 Config | Same App | Same Scopes | +|-----------|---------------|---------------|----------|-------------| +| `testBothStatic_SameApp_SameScopes` | Static (Opaque) | Static (Opaque) | Yes | Yes | +| `testBothStatic_DifferentApps` | Static (Opaque) | Static (JWT) | No | Yes | +| `testBothStatic_SameApp_DifferentScopes` | Static (Opaque, subset) | Static (Opaque, default) | Yes | No | +| `testFirstStatic_SecondDynamic_DifferentApps` | Static (Opaque) | Dynamic (JWT) | No | Yes | +| `testFirstDynamic_SecondStatic_DifferentApps` | Dynamic (JWT) | Static (Opaque) | No | Yes | +| `testBothDynamic_DifferentApps` | Dynamic (Opaque) | Dynamic (JWT) | No | Yes | + +--- + +## Scope Definitions + +| Scope Type | Description | +|------------|-------------| +| **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 | + +## App Configuration Types + +| App Type | Token Format | Description | +|----------|--------------|-------------| +| **CA** | Opaque/JWT | Connected App | +| **ECA** | Opaque/JWT | External Client App | +| **Beacon** | Opaque/JWT | Beacon App | + +### Configuration Modes + +| Mode | Description | +|------|-------------| +| **Static** | Using Login Options "static config" - equivalent to having the settings in bootconfig | +| **Dynamic** | Using Login Options "dynamic config" - only used for that login flow | + +## Available App Configurations + +| Config Name | App Type | Token | Tier | Scopes | +|-------------|----------|-------|------|--------| +| `ecaBasicOpaque` | ECA | Opaque | Basic | `api id refresh_token` | +| `ecaBasicJwt` | ECA | JWT | Basic | `api id refresh_token` | +| `ecaAdvancedOpaque` | ECA | Opaque | Advanced | `api id refresh_token content lightning visualforce sfap_api` | +| `ecaAdvancedJwt` | ECA | JWT | Advanced | `api id refresh_token content lightning visualforce sfap_api` | +| `beaconBasicOpaque` | Beacon | Opaque | Basic | `api id refresh_token` | +| `beaconBasicJwt` | Beacon | JWT | Basic | `api id refresh_token` | +| `beaconAdvancedOpaque` | Beacon | Opaque | Advanced | `api id refresh_token content lightning visualforce sfap_api` | +| `beaconAdvancedJwt` | Beacon | JWT | Advanced | `api id refresh_token content lightning visualforce sfap_api` | +| `caBasicOpaque` | CA | Opaque | Basic | `api id refresh_token` | +| `caBasicJwt` | CA | JWT | Basic | `api id refresh_token` | +| `caAdvancedOpaque` | CA | Opaque | Advanced | `api id refresh_token content lightning visualforce sfap_api` | +| `caAdvancedJwt` | CA | JWT | Advanced | `api id refresh_token content lightning visualforce sfap_api` | + +### Configuration Tiers + +| Tier | Description | Scopes Included | +|------|-------------|-----------------| +| **Basic** | Minimal scopes for basic API access | `api id refresh_token` | +| **Advanced** | Full scopes including hybrid auth capabilities | `api id refresh_token content lightning visualforce sfap_api` | + +### Token Formats + +| Format | Description | +|--------|-------------| +| **Opaque** | Opaque access tokens | +| **JWT** | JSON Web Token based access tokens | + diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/test_config.json.sample b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/test_config.json.sample new file mode 100644 index 0000000000..109a70d952 --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/test_config.json.sample @@ -0,0 +1,87 @@ +{ + "loginHost": "https://testorg.my.salesforce.com", + "apps": [ + { + "name": "ca_basic_opaque", + "consumerKey": "your_consumer_key_here", + "redirectUri": "cabasicopaque://success/done", + "scopes": "api id refresh_token" + }, + { + "name": "ca_basic_jwt", + "consumerKey": "your_consumer_key_here", + "redirectUri": "cabasicjwt://success/done", + "scopes": "api id refresh_token" + }, + { + "name": "ca_advanced_opaque", + "consumerKey": "your_consumer_key_here", + "redirectUri": "caadvancedopaque://success/done", + "scopes": "api content id lightning refresh_token sfap_api visualforce web" + }, + { + "name": "ca_advanced_jwt", + "consumerKey": "your_consumer_key_here", + "redirectUri": "caadvancedjwt://success/done", + "scopes": "api content id lightning refresh_token sfap_api visualforce web" + }, + { + "name": "eca_basic_opaque", + "consumerKey": "your_consumer_key_here", + "redirectUri": "ecabasicopaque://success/done", + "scopes": "api id refresh_token" + }, + { + "name": "eca_basic_jwt", + "consumerKey": "your_consumer_key_here", + "redirectUri": "ecabasicjwt://success/done", + "scopes": "api id refresh_token" + }, + { + "name": "eca_advanced_opaque", + "consumerKey": "your_consumer_key_here", + "redirectUri": "ecaadvancedopaque://success/done", + "scopes": "api content id lightning refresh_token sfap_api visualforce web" + }, + { + "name": "eca_advanced_jwt", + "consumerKey": "your_consumer_key_here", + "redirectUri": "ecaadvancedjwt://success/done", + "scopes": "api content id lightning refresh_token sfap_api visualforce web" + }, + { + "name": "beacon_basic_opaque", + "consumerKey": "your_consumer_key_here", + "redirectUri": "beaconbasicopaque://success/done", + "scopes": "api id refresh_token" + }, + { + "name": "beacon_basic_jwt", + "consumerKey": "your_consumer_key_here", + "redirectUri": "beaconbasicjwt://success/done", + "scopes": "api id refresh_token" + }, + { + "name": "beacon_advanced_opaque", + "consumerKey": "your_consumer_key_here", + "redirectUri": "beaconadvancedopaque://success/done", + "scopes": "api content id lightning refresh_token sfap_api visualforce web" + }, + { + "name": "beacon_advanced_jwt", + "consumerKey": "your_consumer_key_here", + "redirectUri": "beaconadvancedjwt://success/done", + "scopes": "api content id lightning refresh_token sfap_api visualforce web" + } + ], + "users": [ + { + "username": "testios1@testorg.com", + "password": "yourPasswordHere" + }, + { + "username": "testios2@testorg.com", + "password": "yourPasswordHere" + } + ] +}