From cba3d81b46daa503b3541c6649527f4d3b4b0c8d Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 19 Jun 2026 12:06:12 -0700 Subject: [PATCH 1/4] Fix Login for Admin and Welcome Discovery incompatability. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Classes/Login/SFLoginViewController.m | 55 +++- .../Classes/OAuth/SFSDKAuthRequest.h | 12 + .../UserAccount/SFUserAccountManager.h | 6 + .../UserAccount/SFUserAccountManager.m | 52 +++- .../LoginForAdminTests.swift | 289 ++++++++++++++++-- .../SalesforceSDKCoreTests-Bridging-Header.h | 8 + 6 files changed, 394 insertions(+), 28 deletions(-) diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m index fe93a7863f..0180ff5acb 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m @@ -42,6 +42,9 @@ #import "SalesforceSDKManager+Internal.h" #import #import "SFRestAPI+Internal.h" +#import "SFOAuthCoordinator+Internal.h" +#import "SFSDKAuthSession.h" +#import "SFSDKAuthRequest.h" #import @interface SFLoginViewController () @@ -323,14 +326,29 @@ - (UIBarButtonItem *)createSettingsButton { } // Login for Admin - forces browser-based (advanced) authentication to support phishing-resistant MFA. - [menuActions addObject:[UIAction actionWithTitle:[SFSDKResourceUtils localizedString:@"LOGIN_FOR_ADMIN"] + // Wrapped in an uncached UIDeferredMenuElement so the show/hide predicate is re-evaluated each + // time the menu opens. The entry is hidden during phase 1 of Welcome Discovery (i.e. before the + // user has selected an account on welcome.salesforce.com/discovery), where we have no resolved + // My Domain to launch the browser session against. + __weak typeof(self) weakSelf = self; + UIDeferredMenuElement *loginForAdminElement = [UIDeferredMenuElement elementWithUncachedProvider:^(void (^ _Nonnull completion)(NSArray * _Nonnull)) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (![SFLoginViewController shouldShowLoginForAdminForSession:[strongSelf currentAuthSessionForMenu]]) { + completion(@[]); + return; + } + UIAction *action = [UIAction actionWithTitle:[SFSDKResourceUtils localizedString:@"LOGIN_FOR_ADMIN"] image:nil identifier:nil handler:^(__kindof UIAction* _Nonnull action) { - if ([self.delegate respondsToSelector:@selector(loginViewControllerDidSelectLoginForAdmin:)]) { - [self.delegate loginViewControllerDidSelectLoginForAdmin:self]; - } - }]]; + __strong typeof(weakSelf) handlerSelf = weakSelf; + if ([handlerSelf.delegate respondsToSelector:@selector(loginViewControllerDidSelectLoginForAdmin:)]) { + [handlerSelf.delegate loginViewControllerDidSelectLoginForAdmin:handlerSelf]; + } + }]; + completion(@[action]); + }]; + [menuActions addObject:loginForAdminElement]; UIMenu *menu = [UIMenu menuWithTitle:@"" // No title children:menuActions]; @@ -340,6 +358,33 @@ - (UIBarButtonItem *)createSettingsButton { return settingsButton; } +- (nullable SFSDKAuthSession *)currentAuthSessionForMenu { + NSString *sceneId = self.view.window.windowScene.session.persistentIdentifier; + if (sceneId.length == 0) { + return nil; + } + return [[SFUserAccountManager sharedInstance].authSessions objectForKey:sceneId]; +} + ++ (BOOL)shouldShowLoginForAdminForSession:(nullable SFSDKAuthSession *)session { + // Default to showing the entry when we have no session/coordinator info. + // This matches the previous (always-shown) behavior on non-discovery flows + // and during early SDK lifecycle before the auth session is wired up. + SFOAuthCoordinator *coordinator = session.oauthCoordinator; + if (session == nil || coordinator == nil) { + return YES; + } + + NSString *loginHost = session.oauthRequest.loginHost; + SFDomainDiscoveryCoordinator *discoveryCoordinator = [[SFDomainDiscoveryCoordinator alloc] init]; + BOOL isDiscoveryHost = [discoveryCoordinator isDiscoveryDomain:loginHost]; + // Hide only when we are mid-discovery (no My Domain selected yet). + if (isDiscoveryHost && !coordinator.domainUpdated) { + return NO; + } + return YES; +} + - (UIView *)createTitleItem { NSString *title = [SFSDKResourceUtils localizedString:@"TITLE_LOGIN"]; // Setup top item. diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthRequest.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthRequest.h index adb4c12849..e5221f8315 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthRequest.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthRequest.h @@ -37,6 +37,18 @@ NS_ASSUME_NONNULL_BEGIN /// When YES, cancelling the browser session returns to the WebView login instead of showing the server picker. @property (nonatomic, assign) BOOL loginAsAdmin; +/// Login-for-Admin override: the My Domain to authenticate against, set when +/// LFA is invoked from phase 2 of Welcome Discovery. Consulted only while +/// `loginAsAdmin == YES`; the request's `loginHost` is left unchanged so that +/// other settings actions (Reload, Clear Cache) and the post-cancel restart +/// continue to operate against the originally configured login host. +/// Cleared together with `loginAsAdmin` when the LFA browser session is cancelled. +@property (nonatomic, copy, nullable) NSString *loginAsAdminMyDomain; + +/// Login-for-Admin override: the login_hint OAuth parameter to pass to the +/// browser session. Same scoping rules as `loginAsAdminMyDomain`. +@property (nonatomic, copy, nullable) NSString *loginAsAdminLoginHint; + @property (nonatomic, strong) NSArray *additionalOAuthParameterKeys; @property (nonatomic, strong) NSDictionary * additionalTokenRefreshParams; @property (nonatomic, copy) NSString *loginHost; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h index bae8a9e173..72dcaeaab3 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h @@ -651,6 +651,12 @@ Use this method to stop/clear any authentication which is has already been start 2. Walk the key window's view hierarchy and locate the `SFLoginViewController` presented inside the SDK's navigation controller. + @note Welcome Discovery: when the active login host is a Welcome Discovery host + (e.g. `welcome.salesforce.com/discovery`) and the user has not yet selected an + account, "Login for Admin" is a no-op (there is no resolved My Domain to switch + to). After the user picks an account on the discovery page, this method may be + called again and will use the resolved My Domain for the browser session. + @param loginViewController The login view controller whose scene's active auth session should switch to "Login for Admin". Its window's scene is used to locate the session. */ diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m index 822adf8c26..9b7f5ab900 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m @@ -608,26 +608,38 @@ - (BOOL)authenticateWithRequest:(SFSDKAuthRequest *)request initWithFrontdoorBridgeUrl:frontDoorBridgeUrl codeVerifier:codeVerifier]; } - authSession.oauthCoordinator.loginHint = loginHint; + // Login for Admin: when the request carries a My Domain override (set by + // loginViewControllerDidSelectLoginForAdmin: in phase-2 Welcome Discovery), + // route the browser session to the resolved My Domain and forward the + // captured login hint, while leaving request.loginHost — and therefore + // every other restart path — pointed at the originally configured host. + BOOL useLfaOverride = request.loginAsAdmin && request.loginAsAdminMyDomain.length > 0; + if (useLfaOverride) { + authSession.credentials.domain = request.loginAsAdminMyDomain; + authSession.oauthCoordinator.loginHint = request.loginAsAdminLoginHint; + } else { + authSession.oauthCoordinator.loginHint = loginHint; + } + NSString *appConfigLoginHost = useLfaOverride ? request.loginAsAdminMyDomain : request.loginHost; NSString *sceneId = authSession.sceneId; self.authSessions[sceneId] = authSession; - + if (self.nativeLoginEnabled && !self.shouldFallbackToWebAuthentication) { authSession.oauthCoordinator.useNativeAuth = YES; } - + dispatch_async(dispatch_get_main_queue(), ^{ [SFSDKWebViewStateManager removeSessionForcefullyWithCompletionHandler:^{ // Get app config for the login host. If appConfigRuntimeSelectorBlock is set, // it will be invoked to select the appropriate config. Otherwise, returns the default appConfig. - [[SalesforceSDKManager sharedManager] appConfigForLoginHost:request.loginHost callback:^(SFSDKAppConfig* appConfig) { + [[SalesforceSDKManager sharedManager] appConfigForLoginHost:appConfigLoginHost callback:^(SFSDKAppConfig* appConfig) { authSession.credentials.clientId = appConfig.remoteAccessConsumerKey; authSession.credentials.redirectUri = appConfig.oauthRedirectURI; authSession.credentials.scopes = [appConfig.oauthScopes allObjects]; [authSession.oauthCoordinator authenticateWithCredentials:authSession.credentials]; }]; }]; - + }); return self.authSessions[sceneId].isAuthenticating; } @@ -702,6 +714,9 @@ - (void)restartAuthentication:(SFSDKAuthSession *)session { [self dismissAuthViewControllerIfPresentForScene:scene completion:^{ __strong typeof(weakSelf) strongSelf = weakSelf; strongSelf.authSessions[scene.session.persistentIdentifier].isAuthenticating = NO; + // LFA passes its hint via the request's loginAsAdminLoginHint override + // (consulted in authenticateWithRequest:); other restart paths intentionally + // pass nil so a hint set on a prior session does not bleed across server changes. [strongSelf authenticateWithRequest:session.oauthRequest loginHint:nil completion:session.authSuccessCallback @@ -1032,9 +1047,14 @@ - (void)oauthCoordinatorDidCancelBrowserAuthentication:(SFOAuthCoordinator *)coo } // When "Login for Admin" initiated the browser auth, clear the flag and - // restart the WebView login flow instead of showing the server picker. + // its My Domain / login hint overrides, then restart the WebView login + // flow against the originally configured host instead of showing the + // server picker. For Welcome Discovery, this means the user lands back + // on the discovery page and re-picks an account. if (coordinator.authSession.oauthRequest.loginAsAdmin) { coordinator.authSession.oauthRequest.loginAsAdmin = NO; + coordinator.authSession.oauthRequest.loginAsAdminMyDomain = nil; + coordinator.authSession.oauthRequest.loginAsAdminLoginHint = nil; [self restartAuthentication:coordinator.authSession]; return; } @@ -1125,6 +1145,26 @@ - (void)loginViewControllerDidReload:(SFLoginViewController *)loginViewControlle - (void)loginViewControllerDidSelectLoginForAdmin:(SFLoginViewController *)loginViewController { NSString *sceneId = loginViewController.view.window.windowScene.session.persistentIdentifier; SFSDKAuthSession *session = self.authSessions[sceneId]; + SFOAuthCoordinator *coordinator = session.oauthCoordinator; + + // Phase-1 Welcome Discovery: a discovery host is loaded but the user has not + // yet picked an account, so credentials.domain is still the discovery host + // and we have no My Domain to advance to. Switching to ASWebAuthenticationSession + // here would launch the browser against welcome.salesforce.com — wrong UX. + // No-op until phase 2 lands. + SFDomainDiscoveryCoordinator *discoveryCoordinator = [[SFDomainDiscoveryCoordinator alloc] init]; + if ([discoveryCoordinator isDiscoveryDomain:session.oauthRequest.loginHost] && !coordinator.domainUpdated) { + [SFSDKCoreLogger w:[self class] format:@"%@: Login for Admin is not available before a My Domain has been selected on the Welcome Discovery page; ignoring.", NSStringFromSelector(_cmd)]; + return; + } + + // Phase-2 Welcome Discovery (or a non-discovery host): record the resolved + // My Domain and login hint as LFA-scoped overrides on the request. The + // request's loginHost is left untouched so that Reload / Clear Cache / + // post-cancel restart continue to use the originally configured host. + // These overrides are in-memory only and are cleared on LFA cancel. + session.oauthRequest.loginAsAdminMyDomain = coordinator.credentials.domain.length > 0 ? coordinator.credentials.domain : nil; + session.oauthRequest.loginAsAdminLoginHint = coordinator.loginHint.length > 0 ? coordinator.loginHint : nil; session.oauthRequest.loginAsAdmin = YES; [self restartAuthenticationForViewController:loginViewController]; } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift index 60497edcae..d8f59f2c3c 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift @@ -46,12 +46,12 @@ class LoginForAdminTests: XCTestCase { // MARK: - SFSDKAuthRequest loginAsAdmin Property - func testGivenNewAuthRequest_whenCreated_thenLoginAsAdminIsFalse() { + func testGivenNewAuthRequest_whenCreated_thenloginAsAdminIsFalse() { let request = SFSDKAuthRequest() XCTAssertFalse(request.loginAsAdmin, "loginAsAdmin should default to false") } - func testGivenAuthRequest_whenLoginAsAdminSet_thenUseBrowserAuthUnchanged() { + func testGivenAuthRequest_whenloginAsAdminSet_thenUseBrowserAuthUnchanged() { let request = SFSDKAuthRequest() XCTAssertFalse(request.useBrowserAuth, "useBrowserAuth should default to false") @@ -63,7 +63,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - SFSDKAuthSession Coordinator Initialization - func testGivenLoginAsAdmin_whenAuthSessionCreated_thenCoordinatorUsesBrowserAuth() { + func testGivenloginAsAdmin_whenAuthSessionCreated_thenCoordinatorUsesBrowserAuth() { let request = makeAuthRequest() request.loginAsAdmin = true request.useBrowserAuth = false @@ -95,7 +95,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - SFOAuthCoordinator Auth Info Type - func testGivenLoginAsAdmin_whenAuthenticate_thenAuthInfoIsAdvancedBrowser() { + func testGivenloginAsAdmin_whenAuthenticate_thenAuthInfoIsAdvancedBrowser() { createTestAppIdentity() let request = makeAuthRequest() @@ -149,7 +149,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - Approval URL Web Server Flow - func testGivenLoginAsAdmin_whenGenerateApprovalUrl_thenUsesWebServerFlow() { + func testGivenloginAsAdmin_whenGenerateApprovalUrl_thenUsesWebServerFlow() { createTestAppIdentity() SalesforceManager.shared.useWebServerAuthentication = false @@ -165,7 +165,7 @@ class LoginForAdminTests: XCTestCase { "Approval URL should not use response_type=token when loginAsAdmin is true") } - func testGivenNoLoginAsAdmin_whenWebServerAuthDisabled_thenUsesUserAgentFlow() { + func testGivenNologinAsAdmin_whenWebServerAuthDisabled_thenUsesUserAgentFlow() { createTestAppIdentity() SalesforceManager.shared.useWebServerAuthentication = false SalesforceManager.shared.useHybridAuthentication = false @@ -183,7 +183,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - No Global State Mutation - func testGivenLoginAsAdmin_whenSet_thenGlobalWebServerAuthUnchanged() { + func testGivenloginAsAdmin_whenSet_thenGlobalWebServerAuthUnchanged() { let originalValue = SalesforceManager.shared.useWebServerAuthentication let request = SFSDKAuthRequest() @@ -193,7 +193,7 @@ class LoginForAdminTests: XCTestCase { "Setting loginAsAdmin should not change the global useWebServerAuthentication") } - func testGivenLoginAsAdmin_whenAuthSessionCreated_thenGlobalStateUnchanged() { + func testGivenloginAsAdmin_whenAuthSessionCreated_thenGlobalStateUnchanged() { SalesforceManager.shared.useWebServerAuthentication = false let request = makeAuthRequest() @@ -209,7 +209,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - Cancel Flow: loginAsAdmin Clears on Cancel - func testGivenLoginAsAdmin_whenCancelled_thenLoginAsAdminCleared() { + func testGivenloginAsAdmin_whenCancelled_thenloginAsAdminCleared() { let request = makeAuthRequest() request.loginAsAdmin = true @@ -226,7 +226,7 @@ class LoginForAdminTests: XCTestCase { "Coordinator should not use browser auth after loginAsAdmin is cleared") } - func testGivenLoginAsAdminCancelled_whenNewSession_thenAuthInfoMatchesGlobalSetting() { + func testGivenloginAsAdminCancelled_whenNewSession_thenAuthInfoMatchesGlobalSetting() { createTestAppIdentity() SalesforceManager.shared.useWebServerAuthentication = true @@ -270,7 +270,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - useBrowserAuth Not Modified - func testGivenLoginAsAdmin_whenFullLifecycle_thenUseBrowserAuthNeverMutated() { + func testGivenloginAsAdmin_whenFullLifecycle_thenUseBrowserAuthNeverMutated() { let request = makeAuthRequest() request.useBrowserAuth = false @@ -302,7 +302,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - SFUserAccountManager Cancel Browser Auth (loginAsAdmin path) - func testGivenLoginAsAdmin_whenBrowserAuthCancelled_thenLoginAsAdminCleared() { + func testGivenloginAsAdmin_whenBrowserAuthCancelled_thenloginAsAdminCleared() { let request = makeAuthRequest() request.loginAsAdmin = true @@ -317,7 +317,7 @@ class LoginForAdminTests: XCTestCase { XCTAssertFalse(session.oauthRequest.loginAsAdmin, "loginAsAdmin should be cleared after cancel") } - func testGivenLoginAsAdmin_whenBrowserAuthCancelled_thenUserCancelledNotificationNotPosted() { + func testGivenloginAsAdmin_whenBrowserAuthCancelled_thenUserCancelledNotificationNotPosted() { let request = makeAuthRequest() request.loginAsAdmin = true @@ -340,7 +340,7 @@ class LoginForAdminTests: XCTestCase { NotificationCenter.default.removeObserver(observer) } - func testGivenNoLoginAsAdmin_whenBrowserAuthCancelled_thenUserCancelledNotificationPosted() { + func testGivenNologinAsAdmin_whenBrowserAuthCancelled_thenUserCancelledNotificationPosted() { let request = makeAuthRequest() request.loginAsAdmin = false request.useBrowserAuth = false @@ -373,7 +373,7 @@ class LoginForAdminTests: XCTestCase { UserAccountManager.shared.authCancelledByUserHandlerBlock = nil } - func testGivenLoginAsAdmin_whenBrowserAuthCancelled_thenHandlerBlockNotCalled() { + func testGivenloginAsAdmin_whenBrowserAuthCancelled_thenHandlerBlockNotCalled() { let request = makeAuthRequest() request.loginAsAdmin = true @@ -444,7 +444,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - SFUserAccountManager loginViewControllerDidSelectLoginForAdmin - func testGivenAuthSession_whenLoginForAdminSelected_thenLoginAsAdminSetAndAuthRestarted() { + func testGivenAuthSession_whenLoginForAdminSelected_thenloginAsAdminSetAndAuthRestarted() { let uam = UserAccountManager.shared // Get the test app's active window scene to obtain a real sceneId @@ -461,6 +461,9 @@ class LoginForAdminTests: XCTestCase { uam.authSessions[sceneId as NSString] = session XCTAssertFalse(session.oauthRequest.loginAsAdmin, "loginAsAdmin should be false before selecting Login for Admin") + let initialDomain = session.oauthCoordinator.credentials?.domain + XCTAssertEqual(session.oauthRequest.loginHost, initialDomain, + "Test precondition: oauthRequest.loginHost should equal coordinator credentials.domain on a fresh non-discovery session") // Create a SalesforceLoginViewController and place it in the window so its // view.window.windowScene resolves to the same scene @@ -476,14 +479,230 @@ class LoginForAdminTests: XCTestCase { XCTAssertTrue(session.oauthRequest.loginAsAdmin, "loginAsAdmin should be true after loginViewControllerDidSelectLoginForAdmin:") + // The request's loginHost must NEVER be mutated — LFA carries its My Domain + // through the LFA-scoped override field instead. This is the invariant that + // keeps Reload / Clear Cache / post-cancel-restart pointed at the originally + // configured host. + XCTAssertEqual(session.oauthRequest.loginHost, initialDomain, + "loginHost must remain unchanged regardless of LFA invocation") + XCTAssertEqual(session.oauthRequest.loginAsAdminMyDomain, initialDomain, + "loginAsAdminMyDomain should be set from coordinator.credentials.domain on a non-discovery host") // Clean up uam.authSessions.removeObject(sceneId as NSString) window.rootViewController = nil } + func test_givenPhase1Discovery_whenLoginForAdminSelected_thenIsNoOp() { + let uam = UserAccountManager.shared + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + XCTFail("Test requires a UIWindowScene from the running test app") + return + } + let sceneId = windowScene.session.persistentIdentifier + + // Phase 1 of Welcome Discovery: loginHost is the discovery domain and the + // coordinator has not yet observed a custom domain update. + let request = makeAuthRequest() + request.loginHost = "welcome.salesforce.com/discovery" + request.loginAsAdmin = false + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertFalse(session.oauthCoordinator.domainUpdated, + "Test precondition: coordinator.domainUpdated should be NO in phase 1") + uam.authSessions[sceneId as NSString] = session + + let loginVC = SalesforceLoginViewController() + let window = windowScene.windows.first ?? UIWindow(windowScene: windowScene) + window.rootViewController = loginVC + window.makeKeyAndVisible() + loginVC.loadViewIfNeeded() + + let selector = NSSelectorFromString("loginViewControllerDidSelectLoginForAdmin:") + uam.perform(selector, with: loginVC) + + XCTAssertFalse(session.oauthRequest.loginAsAdmin, + "loginAsAdmin must remain false during phase-1 Welcome Discovery — Login for Admin is a no-op") + XCTAssertEqual(session.oauthRequest.loginHost, "welcome.salesforce.com/discovery", + "loginHost must remain the discovery host") + XCTAssertNil(session.oauthRequest.loginAsAdminMyDomain, + "loginAsAdminMyDomain must remain nil — no override during phase 1") + XCTAssertNil(session.oauthRequest.loginAsAdminLoginHint, + "loginAsAdminLoginHint must remain nil — no override during phase 1") + + uam.authSessions.removeObject(sceneId as NSString) + window.rootViewController = nil + } + + func test_givenPhase2Discovery_whenLoginForAdminSelected_thenMyDomainOverrideSet() { + let uam = UserAccountManager.shared + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + XCTFail("Test requires a UIWindowScene from the running test app") + return + } + let sceneId = windowScene.session.persistentIdentifier + + // Phase 2 of Welcome Discovery: the user has picked an account on the + // discovery page and the coordinator has updated credentials.domain to + // the resolved My Domain. + let request = makeAuthRequest() + request.loginHost = "welcome.salesforce.com/discovery" + request.loginAsAdmin = false + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.domainUpdated = true + session.oauthCoordinator.credentials?.domain = "mycompany.my.salesforce.com" + session.oauthCoordinator.loginHint = "admin@mycompany.com" + uam.authSessions[sceneId as NSString] = session + + let loginVC = SalesforceLoginViewController() + let window = windowScene.windows.first ?? UIWindow(windowScene: windowScene) + window.rootViewController = loginVC + window.makeKeyAndVisible() + loginVC.loadViewIfNeeded() + + let selector = NSSelectorFromString("loginViewControllerDidSelectLoginForAdmin:") + uam.perform(selector, with: loginVC) + + XCTAssertTrue(session.oauthRequest.loginAsAdmin, + "loginAsAdmin should be true after Login for Admin in phase 2") + XCTAssertEqual(session.oauthRequest.loginHost, "welcome.salesforce.com/discovery", + "loginHost must remain the discovery host — Reload / Clear Cache / cancel-restart depend on this invariant") + XCTAssertEqual(session.oauthRequest.loginAsAdminMyDomain, "mycompany.my.salesforce.com", + "loginAsAdminMyDomain should record the resolved My Domain (in-memory only, not persisted)") + XCTAssertEqual(session.oauthRequest.loginAsAdminLoginHint, "admin@mycompany.com", + "loginAsAdminLoginHint should record the discovery-resolved hint so authenticateWithRequest: can forward it") + + uam.authSessions.removeObject(sceneId as NSString) + window.rootViewController = nil + } + + func test_givenPhase2Discovery_whenLoginForAdminSelected_thenLoginHostStorageNotPolluted() { + // The brief explicitly forbids persisting the My Domain to SFSDKLoginHostStorage + // / NSUserDefaults during the discovery → admin transition. + let uam = UserAccountManager.shared + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + XCTFail("Test requires a UIWindowScene from the running test app") + return + } + let sceneId = windowScene.session.persistentIdentifier + + let request = makeAuthRequest() + request.loginHost = "welcome.salesforce.com/discovery" + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.domainUpdated = true + session.oauthCoordinator.credentials?.domain = "mycompany.my.salesforce.com" + uam.authSessions[sceneId as NSString] = session + + let loginVC = SalesforceLoginViewController() + let window = windowScene.windows.first ?? UIWindow(windowScene: windowScene) + window.rootViewController = loginVC + window.makeKeyAndVisible() + loginVC.loadViewIfNeeded() + + let selector = NSSelectorFromString("loginViewControllerDidSelectLoginForAdmin:") + uam.perform(selector, with: loginVC) + + let storedHost = SFSDKLoginHostStorage.sharedInstance().loginHost(forHostAddress: "mycompany.my.salesforce.com") + XCTAssertNil(storedHost, "Login for Admin must not persist the My Domain into SFSDKLoginHostStorage") + + uam.authSessions.removeObject(sceneId as NSString) + window.rootViewController = nil + } + + func test_authRequestRoundTripsloginAsAdminOverrides() { + // The LFA-scoped override fields on SFSDKAuthRequest must round-trip so that + // restartAuthentication: can forward them through authenticateWithRequest:loginHint: + // without mutating the request's permanent loginHost. + let request = makeAuthRequest() + XCTAssertNil(request.loginAsAdminMyDomain, "loginAsAdminMyDomain should default to nil") + XCTAssertNil(request.loginAsAdminLoginHint, "loginAsAdminLoginHint should default to nil") + + request.loginAsAdminMyDomain = "mycompany.my.salesforce.com" + request.loginAsAdminLoginHint = "admin@mycompany.com" + XCTAssertEqual(request.loginAsAdminMyDomain, "mycompany.my.salesforce.com") + XCTAssertEqual(request.loginAsAdminLoginHint, "admin@mycompany.com") + + // After putting the request inside a session, the properties are still observable. + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertEqual(session.oauthRequest.loginAsAdminMyDomain, "mycompany.my.salesforce.com", + "Session.oauthRequest.loginAsAdminMyDomain should match the value set on the request") + XCTAssertEqual(session.oauthRequest.loginAsAdminLoginHint, "admin@mycompany.com", + "Session.oauthRequest.loginAsAdminLoginHint should match the value set on the request") + } + + func test_givenLfaOverridesSet_whenBrowserAuthCancelled_thenOverridesCleared() { + // After the user backs out of the LFA browser session, both overrides and + // the loginAsAdmin flag must be cleared so subsequent settings actions + // (Reload, Clear Cache) and the next browser launch do not pick up stale state. + let uam = UserAccountManager.shared + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + XCTFail("Test requires a UIWindowScene from the running test app") + return + } + let sceneId = windowScene.session.persistentIdentifier + + let request = makeAuthRequest() + request.loginHost = "welcome.salesforce.com/discovery" + request.loginAsAdmin = true + request.loginAsAdminMyDomain = "mycompany.my.salesforce.com" + request.loginAsAdminLoginHint = "admin@mycompany.com" + let session = SFSDKAuthSession(request, credentials: nil) + uam.authSessions[sceneId as NSString] = session + + uam.oauthCoordinatorDidCancelBrowserAuthentication(session.oauthCoordinator) + + XCTAssertFalse(session.oauthRequest.loginAsAdmin, + "loginAsAdmin must be cleared after the LFA browser session is cancelled") + XCTAssertNil(session.oauthRequest.loginAsAdminMyDomain, + "loginAsAdminMyDomain must be cleared on cancel so a subsequent restart uses the original loginHost") + XCTAssertNil(session.oauthRequest.loginAsAdminLoginHint, + "loginAsAdminLoginHint must be cleared on cancel so a subsequent restart does not carry stale hint") + XCTAssertEqual(session.oauthRequest.loginHost, "welcome.salesforce.com/discovery", + "loginHost must remain the originally configured discovery host across the cancel path") + + uam.authSessions.removeObject(sceneId as NSString) + } + + // MARK: - SFLoginViewController.shouldShowLoginForAdminForSession: helper + + func test_givenNilSession_whenShouldShowLoginForAdmin_thenReturnsTrue() { + XCTAssertTrue(SalesforceLoginViewController.shouldShowLoginForAdmin(for: nil), + "Should default to YES (show) when no session is available") + } + + func test_givenNonDiscoveryHost_whenShouldShowLoginForAdmin_thenReturnsTrue() { + let request = makeAuthRequest() + request.loginHost = "login.salesforce.com" + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertTrue(SalesforceLoginViewController.shouldShowLoginForAdmin(for: session), + "Login for Admin should be visible on a non-discovery host") + } + + func test_givenPhase1DiscoveryHost_whenShouldShowLoginForAdmin_thenReturnsFalse() { + let request = makeAuthRequest() + request.loginHost = "welcome.salesforce.com/discovery" + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertFalse(session.oauthCoordinator.domainUpdated, + "Test precondition: domainUpdated == NO for phase 1") + XCTAssertFalse(SalesforceLoginViewController.shouldShowLoginForAdmin(for: session), + "Login for Admin should be hidden in phase 1 of Welcome Discovery") + } + + func test_givenPhase2DiscoveryHost_whenShouldShowLoginForAdmin_thenReturnsTrue() { + let request = makeAuthRequest() + request.loginHost = "welcome.salesforce.com/discovery" + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.domainUpdated = true + session.oauthCoordinator.credentials?.domain = "mycompany.my.salesforce.com" + XCTAssertTrue(SalesforceLoginViewController.shouldShowLoginForAdmin(for: session), + "Login for Admin should be visible once Welcome Discovery has resolved a My Domain (phase 2)") + } + @available(*, deprecated, message: "Exercises deprecated public API") - func testGivenAuthSession_whenPublicLoginForAdminCalled_thenLoginAsAdminSet() { + func testGivenAuthSession_whenPublicLoginForAdminCalled_thenloginAsAdminSet() { let uam = UserAccountManager.shared guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { @@ -516,6 +735,42 @@ class LoginForAdminTests: XCTestCase { window.rootViewController = nil } + @available(*, deprecated, message: "Exercises deprecated public API") + func test_givenPhase1Discovery_whenPublicLoginForAdminCalled_thenIsNoOp() { + let uam = UserAccountManager.shared + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + XCTFail("Test requires a UIWindowScene from the running test app") + return + } + let sceneId = windowScene.session.persistentIdentifier + + let request = makeAuthRequest() + request.loginHost = "welcome.salesforce.com/discovery" + request.loginAsAdmin = false + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertFalse(session.oauthCoordinator.domainUpdated, + "Test precondition: domainUpdated == NO for phase 1") + uam.authSessions[sceneId as NSString] = session + + let loginVC = SalesforceLoginViewController() + let window = windowScene.windows.first ?? UIWindow(windowScene: windowScene) + window.rootViewController = loginVC + window.makeKeyAndVisible() + loginVC.loadViewIfNeeded() + + // Public API should match the protocol method's no-op behavior in phase 1. + uam.loginViewControllerDidSelectLoginForAdmin(loginVC) + + XCTAssertFalse(session.oauthRequest.loginAsAdmin, + "Public loginViewControllerDidSelectLoginForAdmin must no-op during phase-1 discovery") + XCTAssertEqual(session.oauthRequest.loginHost, "welcome.salesforce.com/discovery", + "loginHost must remain unchanged during phase-1 no-op") + + uam.authSessions.removeObject(sceneId as NSString) + window.rootViewController = nil + } + // MARK: - Private Helpers private func makeAuthRequest() -> SFSDKAuthRequest { diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h index b4308059ef..6f0b6b8eb5 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h @@ -10,11 +10,19 @@ #import "SFUserAccountManager+Internal.h" #import "SFOAuthCredentials+Internal.h" #import "SFSDKOAuth2.h" +#import "SFLoginViewController.h" @interface SFOAuthCoordinator (LightningURLTesting) - (void)handleResponse:(SFSDKOAuthTokenEndpointResponse *)response; @end +@interface SFLoginViewController (LoginForAdminTesting) +/// Predicate that controls visibility of the "Login for Admin" entry in the +/// settings menu. Returns NO during phase 1 of Welcome Discovery (a discovery +/// host whose coordinator has not yet observed a custom domain update). ++ (BOOL)shouldShowLoginForAdminForSession:(nullable SFSDKAuthSession *)session; +@end + @interface SFSDKOAuthTokenEndpointResponse (Testing) - (instancetype)initWithDictionary:(NSDictionary *)nvPairs parseAdditionalFields:(NSArray *)additionalOAuthParameterKeys; @end From 731160daae743636f6b8975b2faa0969c0eb1049 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Tue, 23 Jun 2026 17:02:34 -0700 Subject: [PATCH 2/4] Address review: extract isDiscoveryDomain class method; fix test names - Extract the stateless isDiscoveryDomain check to a class method on SFDomainDiscoveryCoordinator so callers no longer alloc a throwaway instance. The instance method delegates to it; existing callers are unchanged. Converts the LFA menu-visibility check, the LFA phase-1 guard, and the pre-existing setCurrentUser discovery check. - Add class-method + instance/class parity tests. - Capitalize "LoginAsAdmin"/"LoginForAdmin" in test method names (property/variable references stay lowercase). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Classes/Login/SFLoginViewController.m | 3 +- .../OAuth/DomainDiscoveryCoordinator.swift | 5 +++ .../UserAccount/SFUserAccountManager.m | 5 ++- .../LoginForAdminTests.swift | 36 +++++++++---------- .../WelcomeDiscoveryLoginHostTests.swift | 30 ++++++++++++++++ 5 files changed, 56 insertions(+), 23 deletions(-) diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m index 0180ff5acb..dea80a889d 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m @@ -376,8 +376,7 @@ + (BOOL)shouldShowLoginForAdminForSession:(nullable SFSDKAuthSession *)session { } NSString *loginHost = session.oauthRequest.loginHost; - SFDomainDiscoveryCoordinator *discoveryCoordinator = [[SFDomainDiscoveryCoordinator alloc] init]; - BOOL isDiscoveryHost = [discoveryCoordinator isDiscoveryDomain:loginHost]; + BOOL isDiscoveryHost = [SFDomainDiscoveryCoordinator isDiscoveryDomain:loginHost]; // Hide only when we are mid-discovery (no My Domain selected yet). if (isDiscoveryHost && !coordinator.domainUpdated) { return NO; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift index 15ff749b97..c34be0e09c 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift @@ -100,6 +100,11 @@ public class DomainDiscoveryCoordinator: NSObject { @objc public func isDiscoveryDomain(_ domain: String?) -> Bool { + return Self.isDiscoveryDomain(domain) + } + + /// Whether the given login host is a My Domain discovery host (e.g.`welcome.salesforce.com/discovery`). + public class func isDiscoveryDomain(_ domain: String?) -> Bool { guard let domain = domain else { return false } let isDiscovery = domain.lowercased().contains(DomainDiscovery.URLComponent.path.rawValue) return isDiscovery diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m index 9b7f5ab900..d7f5df6107 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m @@ -1152,8 +1152,7 @@ - (void)loginViewControllerDidSelectLoginForAdmin:(SFLoginViewController *)login // and we have no My Domain to advance to. Switching to ASWebAuthenticationSession // here would launch the browser against welcome.salesforce.com — wrong UX. // No-op until phase 2 lands. - SFDomainDiscoveryCoordinator *discoveryCoordinator = [[SFDomainDiscoveryCoordinator alloc] init]; - if ([discoveryCoordinator isDiscoveryDomain:session.oauthRequest.loginHost] && !coordinator.domainUpdated) { + if ([SFDomainDiscoveryCoordinator isDiscoveryDomain:session.oauthRequest.loginHost] && !coordinator.domainUpdated) { [SFSDKCoreLogger w:[self class] format:@"%@: Login for Admin is not available before a My Domain has been selected on the Welcome Discovery page; ignoring.", NSStringFromSelector(_cmd)]; return; } @@ -1750,7 +1749,7 @@ - (void)setCurrentUserInternal:(SFUserAccount*)user { // next login is web based it should not try to use that url. // Also skip if the app uses a Welcome/Discovery domain — persisting the My Domain // would pollute the server picker and prevent returning to the discovery page on logout. - BOOL isDiscoveryLogin = [[[SFDomainDiscoveryCoordinator alloc] init] isDiscoveryDomain:self.loginHost]; + BOOL isDiscoveryLogin = [SFDomainDiscoveryCoordinator isDiscoveryDomain:self.loginHost]; if (user.credentials.domain && !isNativeLogin && !isDiscoveryLogin) self.loginHost = user.credentials.domain; [self didChangeValueForKey:@"currentUser"]; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift index d8f59f2c3c..9f9b2c26c6 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift @@ -46,12 +46,12 @@ class LoginForAdminTests: XCTestCase { // MARK: - SFSDKAuthRequest loginAsAdmin Property - func testGivenNewAuthRequest_whenCreated_thenloginAsAdminIsFalse() { + func testGivenNewAuthRequest_whenCreated_thenLoginAsAdminIsFalse() { let request = SFSDKAuthRequest() XCTAssertFalse(request.loginAsAdmin, "loginAsAdmin should default to false") } - func testGivenAuthRequest_whenloginAsAdminSet_thenUseBrowserAuthUnchanged() { + func testGivenAuthRequest_whenLoginAsAdminSet_thenUseBrowserAuthUnchanged() { let request = SFSDKAuthRequest() XCTAssertFalse(request.useBrowserAuth, "useBrowserAuth should default to false") @@ -63,7 +63,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - SFSDKAuthSession Coordinator Initialization - func testGivenloginAsAdmin_whenAuthSessionCreated_thenCoordinatorUsesBrowserAuth() { + func testGivenLoginAsAdmin_whenAuthSessionCreated_thenCoordinatorUsesBrowserAuth() { let request = makeAuthRequest() request.loginAsAdmin = true request.useBrowserAuth = false @@ -95,7 +95,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - SFOAuthCoordinator Auth Info Type - func testGivenloginAsAdmin_whenAuthenticate_thenAuthInfoIsAdvancedBrowser() { + func testGivenLoginAsAdmin_whenAuthenticate_thenAuthInfoIsAdvancedBrowser() { createTestAppIdentity() let request = makeAuthRequest() @@ -149,7 +149,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - Approval URL Web Server Flow - func testGivenloginAsAdmin_whenGenerateApprovalUrl_thenUsesWebServerFlow() { + func testGivenLoginAsAdmin_whenGenerateApprovalUrl_thenUsesWebServerFlow() { createTestAppIdentity() SalesforceManager.shared.useWebServerAuthentication = false @@ -165,7 +165,7 @@ class LoginForAdminTests: XCTestCase { "Approval URL should not use response_type=token when loginAsAdmin is true") } - func testGivenNologinAsAdmin_whenWebServerAuthDisabled_thenUsesUserAgentFlow() { + func testGivenNoLoginAsAdmin_whenWebServerAuthDisabled_thenUsesUserAgentFlow() { createTestAppIdentity() SalesforceManager.shared.useWebServerAuthentication = false SalesforceManager.shared.useHybridAuthentication = false @@ -183,7 +183,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - No Global State Mutation - func testGivenloginAsAdmin_whenSet_thenGlobalWebServerAuthUnchanged() { + func testGivenLoginAsAdmin_whenSet_thenGlobalWebServerAuthUnchanged() { let originalValue = SalesforceManager.shared.useWebServerAuthentication let request = SFSDKAuthRequest() @@ -193,7 +193,7 @@ class LoginForAdminTests: XCTestCase { "Setting loginAsAdmin should not change the global useWebServerAuthentication") } - func testGivenloginAsAdmin_whenAuthSessionCreated_thenGlobalStateUnchanged() { + func testGivenLoginAsAdmin_whenAuthSessionCreated_thenGlobalStateUnchanged() { SalesforceManager.shared.useWebServerAuthentication = false let request = makeAuthRequest() @@ -209,7 +209,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - Cancel Flow: loginAsAdmin Clears on Cancel - func testGivenloginAsAdmin_whenCancelled_thenloginAsAdminCleared() { + func testGivenLoginAsAdmin_whenCancelled_thenLoginAsAdminCleared() { let request = makeAuthRequest() request.loginAsAdmin = true @@ -226,7 +226,7 @@ class LoginForAdminTests: XCTestCase { "Coordinator should not use browser auth after loginAsAdmin is cleared") } - func testGivenloginAsAdminCancelled_whenNewSession_thenAuthInfoMatchesGlobalSetting() { + func testGivenLoginAsAdminCancelled_whenNewSession_thenAuthInfoMatchesGlobalSetting() { createTestAppIdentity() SalesforceManager.shared.useWebServerAuthentication = true @@ -270,7 +270,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - useBrowserAuth Not Modified - func testGivenloginAsAdmin_whenFullLifecycle_thenUseBrowserAuthNeverMutated() { + func testGivenLoginAsAdmin_whenFullLifecycle_thenUseBrowserAuthNeverMutated() { let request = makeAuthRequest() request.useBrowserAuth = false @@ -302,7 +302,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - SFUserAccountManager Cancel Browser Auth (loginAsAdmin path) - func testGivenloginAsAdmin_whenBrowserAuthCancelled_thenloginAsAdminCleared() { + func testGivenLoginAsAdmin_whenBrowserAuthCancelled_thenLoginAsAdminCleared() { let request = makeAuthRequest() request.loginAsAdmin = true @@ -317,7 +317,7 @@ class LoginForAdminTests: XCTestCase { XCTAssertFalse(session.oauthRequest.loginAsAdmin, "loginAsAdmin should be cleared after cancel") } - func testGivenloginAsAdmin_whenBrowserAuthCancelled_thenUserCancelledNotificationNotPosted() { + func testGivenLoginAsAdmin_whenBrowserAuthCancelled_thenUserCancelledNotificationNotPosted() { let request = makeAuthRequest() request.loginAsAdmin = true @@ -340,7 +340,7 @@ class LoginForAdminTests: XCTestCase { NotificationCenter.default.removeObserver(observer) } - func testGivenNologinAsAdmin_whenBrowserAuthCancelled_thenUserCancelledNotificationPosted() { + func testGivenNoLoginAsAdmin_whenBrowserAuthCancelled_thenUserCancelledNotificationPosted() { let request = makeAuthRequest() request.loginAsAdmin = false request.useBrowserAuth = false @@ -373,7 +373,7 @@ class LoginForAdminTests: XCTestCase { UserAccountManager.shared.authCancelledByUserHandlerBlock = nil } - func testGivenloginAsAdmin_whenBrowserAuthCancelled_thenHandlerBlockNotCalled() { + func testGivenLoginAsAdmin_whenBrowserAuthCancelled_thenHandlerBlockNotCalled() { let request = makeAuthRequest() request.loginAsAdmin = true @@ -444,7 +444,7 @@ class LoginForAdminTests: XCTestCase { // MARK: - SFUserAccountManager loginViewControllerDidSelectLoginForAdmin - func testGivenAuthSession_whenLoginForAdminSelected_thenloginAsAdminSetAndAuthRestarted() { + func testGivenAuthSession_whenLoginForAdminSelected_thenLoginAsAdminSetAndAuthRestarted() { let uam = UserAccountManager.shared // Get the test app's active window scene to obtain a real sceneId @@ -611,7 +611,7 @@ class LoginForAdminTests: XCTestCase { window.rootViewController = nil } - func test_authRequestRoundTripsloginAsAdminOverrides() { + func test_authRequestRoundTripsLoginAsAdminOverrides() { // The LFA-scoped override fields on SFSDKAuthRequest must round-trip so that // restartAuthentication: can forward them through authenticateWithRequest:loginHint: // without mutating the request's permanent loginHost. @@ -702,7 +702,7 @@ class LoginForAdminTests: XCTestCase { } @available(*, deprecated, message: "Exercises deprecated public API") - func testGivenAuthSession_whenPublicLoginForAdminCalled_thenloginAsAdminSet() { + func testGivenAuthSession_whenPublicLoginForAdminCalled_thenLoginAsAdminSet() { let uam = UserAccountManager.shared guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/WelcomeDiscoveryLoginHostTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/WelcomeDiscoveryLoginHostTests.swift index 2595a74cb1..05e28f0c7e 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/WelcomeDiscoveryLoginHostTests.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/WelcomeDiscoveryLoginHostTests.swift @@ -85,6 +85,36 @@ final class WelcomeDiscoveryLoginHostTests: XCTestCase { XCTAssertFalse(coordinator.isDiscoveryDomain(nil)) } + // MARK: - DomainDiscoveryCoordinator.isDiscoveryDomain class method tests + + func test_givenDiscoveryDomain_whenCheckingIsDiscoveryDomainClassMethod_thenReturnsTrue() { + XCTAssertTrue(DomainDiscoveryCoordinator.isDiscoveryDomain(discoveryDomain)) + XCTAssertTrue(DomainDiscoveryCoordinator.isDiscoveryDomain("welcome.salesforce.com/discovery")) + XCTAssertTrue(DomainDiscoveryCoordinator.isDiscoveryDomain("mycompany.salesforce.com/discovery")) + } + + func test_givenNonDiscoveryDomain_whenCheckingIsDiscoveryDomainClassMethod_thenReturnsFalse() { + XCTAssertFalse(DomainDiscoveryCoordinator.isDiscoveryDomain(myDomain)) + XCTAssertFalse(DomainDiscoveryCoordinator.isDiscoveryDomain("login.salesforce.com")) + XCTAssertFalse(DomainDiscoveryCoordinator.isDiscoveryDomain("test.salesforce.com")) + } + + func test_givenNilDomain_whenCheckingIsDiscoveryDomainClassMethod_thenReturnsFalse() { + XCTAssertFalse(DomainDiscoveryCoordinator.isDiscoveryDomain(nil)) + } + + func test_givenAnyDomain_whenComparingClassAndInstanceIsDiscoveryDomain_thenResultsMatch() { + // The instance method now delegates to the class method; both must agree so + // existing callers (e.g. SFOAuthCoordinator) and the new class-method callers + // stay in lockstep. + let coordinator = DomainDiscoveryCoordinator() + for domain in [discoveryDomain, myDomain, "login.salesforce.com", "x.salesforce.com/discovery", nil] { + XCTAssertEqual(coordinator.isDiscoveryDomain(domain), + DomainDiscoveryCoordinator.isDiscoveryDomain(domain), + "Instance and class isDiscoveryDomain must agree for \(domain ?? "nil")") + } + } + // MARK: - Login host persistence tests func test_givenDiscoveryLoginHost_whenSetCurrentUser_thenLoginHostNotOverwritten() { From 25b18787c44b429a0a1cf3dca9df355fdadf245a Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Tue, 23 Jun 2026 17:18:01 -0700 Subject: [PATCH 3/4] Add missing @objc to isDiscoveryDomain class method The class method must be @objc so ObjC callers (SFUserAccountManager.m, SFLoginViewController.m) can resolve +isDiscoveryDomain: through the generated -Swift.h header. Without it CI failed with "no known class method for selector 'isDiscoveryDomain:'". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Classes/OAuth/DomainDiscoveryCoordinator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift index c34be0e09c..e21201013a 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift @@ -104,6 +104,7 @@ public class DomainDiscoveryCoordinator: NSObject { } /// Whether the given login host is a My Domain discovery host (e.g.`welcome.salesforce.com/discovery`). + @objc public class func isDiscoveryDomain(_ domain: String?) -> Bool { guard let domain = domain else { return false } let isDiscovery = domain.lowercased().contains(DomainDiscovery.URLComponent.path.rawValue) From 54b99a273bc26970a209847b22a988327788ca9c Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Tue, 23 Jun 2026 17:33:54 -0700 Subject: [PATCH 4/4] Deprecate instance isDiscoveryDomain(_:) in favor of class method The stateless discovery-host check now lives on the class method, so the instance method is redundant. Mark it @available deprecated (removed in 15.0) and migrate the last production caller (SFOAuthCoordinator) to the class method. Annotate the instance-method tests as exercising deprecated API so the build stays warning-free while coverage of the deprecated path is retained until removal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Classes/OAuth/DomainDiscoveryCoordinator.swift | 5 +++-- .../Classes/OAuth/SFOAuthCoordinator.m | 2 +- .../WelcomeDiscoveryLoginHostTests.swift | 11 +++++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift index e21201013a..a8abc1fd80 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DomainDiscoveryCoordinator.swift @@ -95,10 +95,11 @@ public class DomainDiscoveryCoordinator: NSObject { @objc @available(*, deprecated, renamed: "isDiscoveryDomain(domain:)") public func isDiscoveryDomain(_ domain: String?, clientId: String?) -> Bool { - return isDiscoveryDomain(domain) + return Self.isDiscoveryDomain(domain) } - + @objc + @available(*, deprecated, message: "Deprecated in Salesforce Mobile SDK 14.0 and will be removed in 15.0. Use the class method DomainDiscoveryCoordinator.isDiscoveryDomain(_:) instead; the check is stateless and needs no instance.") public func isDiscoveryDomain(_ domain: String?) -> Bool { return Self.isDiscoveryDomain(domain) } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m index 736bac782f..307a60eaf1 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m @@ -216,7 +216,7 @@ - (void)authenticate { - (void)authenticateWithCredentials:(SFOAuthCredentials *)credentials { self.credentials = credentials; - if ([self.domainDiscoveryCoordinator isDiscoveryDomain:self.credentials.domain]) { + if ([SFDomainDiscoveryCoordinator isDiscoveryDomain:self.credentials.domain]) { [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureWelcomeDiscovery]; [self runMyDomainDiscoveryAndAuthenticate]; return; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/WelcomeDiscoveryLoginHostTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/WelcomeDiscoveryLoginHostTests.swift index 05e28f0c7e..b479aa691b 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/WelcomeDiscoveryLoginHostTests.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/WelcomeDiscoveryLoginHostTests.swift @@ -64,8 +64,9 @@ final class WelcomeDiscoveryLoginHostTests: XCTestCase { } } - // MARK: - DomainDiscoveryCoordinator.isDiscoveryDomain tests + // MARK: - DomainDiscoveryCoordinator.isDiscoveryDomain instance method tests (deprecated) + @available(*, deprecated, message: "Exercises deprecated instance API") func test_givenDiscoveryDomain_whenCheckingIsDiscoveryDomain_thenReturnsTrue() { let coordinator = DomainDiscoveryCoordinator() XCTAssertTrue(coordinator.isDiscoveryDomain(discoveryDomain)) @@ -73,6 +74,7 @@ final class WelcomeDiscoveryLoginHostTests: XCTestCase { XCTAssertTrue(coordinator.isDiscoveryDomain("mycompany.salesforce.com/discovery")) } + @available(*, deprecated, message: "Exercises deprecated instance API") func test_givenNonDiscoveryDomain_whenCheckingIsDiscoveryDomain_thenReturnsFalse() { let coordinator = DomainDiscoveryCoordinator() XCTAssertFalse(coordinator.isDiscoveryDomain(myDomain)) @@ -80,6 +82,7 @@ final class WelcomeDiscoveryLoginHostTests: XCTestCase { XCTAssertFalse(coordinator.isDiscoveryDomain("test.salesforce.com")) } + @available(*, deprecated, message: "Exercises deprecated instance API") func test_givenNilDomain_whenCheckingIsDiscoveryDomain_thenReturnsFalse() { let coordinator = DomainDiscoveryCoordinator() XCTAssertFalse(coordinator.isDiscoveryDomain(nil)) @@ -103,10 +106,10 @@ final class WelcomeDiscoveryLoginHostTests: XCTestCase { XCTAssertFalse(DomainDiscoveryCoordinator.isDiscoveryDomain(nil)) } + @available(*, deprecated, message: "Exercises deprecated instance API") func test_givenAnyDomain_whenComparingClassAndInstanceIsDiscoveryDomain_thenResultsMatch() { - // The instance method now delegates to the class method; both must agree so - // existing callers (e.g. SFOAuthCoordinator) and the new class-method callers - // stay in lockstep. + // The deprecated instance method delegates to the class method; both must agree + // so any remaining instance-method caller stays in lockstep with the class method. let coordinator = DomainDiscoveryCoordinator() for domain in [discoveryDomain, myDomain, "login.salesforce.com", "x.salesforce.com/discovery", nil] { XCTAssertEqual(coordinator.isDiscoveryDomain(domain),