diff --git a/Networking/Networking/Model/WordPressSite.swift b/Networking/Networking/Model/WordPressSite.swift index 6932d325820..e4335373c44 100644 --- a/Networking/Networking/Model/WordPressSite.swift +++ b/Networking/Networking/Model/WordPressSite.swift @@ -24,12 +24,23 @@ public struct WordPressSite: Decodable, Equatable { /// public let gmtOffset: String - public init(name: String, description: String, url: String, timezone: String, gmtOffset: String) { + /// Namespaces supported by the site. + /// + public let namespaces: [String] + + /// Whether WooCommerce is one of the active plugins in the site. + /// + public var isWooCommerceActive: Bool { + namespaces.contains { $0.hasPrefix(Constants.wooNameSpace) } + } + + public init(name: String, description: String, url: String, timezone: String, gmtOffset: String, namespaces: [String]) { self.name = name self.description = description self.url = url self.timezone = timezone self.gmtOffset = gmtOffset + self.namespaces = namespaces } } @@ -47,7 +58,7 @@ public extension WordPressSite { plan: "", isJetpackThePluginInstalled: false, isJetpackConnected: false, - isWooCommerceActive: true, // we expect to only call this after checking Woo is active + isWooCommerceActive: isWooCommerceActive, isWordPressComStore: false, jetpackConnectionActivePlugins: [], timezone: timezone, @@ -64,10 +75,12 @@ private extension WordPressSite { case url case timezone = "timezone_string" case gmtOffset = "gmt_offset" + case namespaces } enum Constants { static let adminPath = "/wp-admin" static let loginPath = "/wp-login.php" + static let wooNameSpace = "wc/" } } diff --git a/Networking/NetworkingTests/Mapper/WordPressSiteMapperTests.swift b/Networking/NetworkingTests/Mapper/WordPressSiteMapperTests.swift index 61a31368355..f26823be6aa 100644 --- a/Networking/NetworkingTests/Mapper/WordPressSiteMapperTests.swift +++ b/Networking/NetworkingTests/Mapper/WordPressSiteMapperTests.swift @@ -13,6 +13,8 @@ final class WordPressSiteMapperTests: XCTestCase { XCTAssertEqual(site.url, "https://test.com") XCTAssertEqual(site.gmtOffset, "0") XCTAssertEqual(site.timezone, "") + XCTAssertFalse(site.namespaces.isEmpty) + XCTAssertFalse(site.isWooCommerceActive) } } diff --git a/Networking/NetworkingTests/Responses/wordpress-site-info.json b/Networking/NetworkingTests/Responses/wordpress-site-info.json index ef9cbf2a653..ad64e79b775 100644 --- a/Networking/NetworkingTests/Responses/wordpress-site-info.json +++ b/Networking/NetworkingTests/Responses/wordpress-site-info.json @@ -6,4 +6,10 @@ "gmt_offset": "0", "timezone_string": "", "authentication": [], + "namespaces": [ + "oembed/1.0", + "wp/v2", + "wp-site-health/v1", + "wp-block-editor/v1" + ] } diff --git a/WooCommerce/Classes/Authentication/AuthenticationManager.swift b/WooCommerce/Classes/Authentication/AuthenticationManager.swift index 22cccbdc694..4da76a135bf 100644 --- a/WooCommerce/Classes/Authentication/AuthenticationManager.swift +++ b/WooCommerce/Classes/Authentication/AuthenticationManager.swift @@ -3,15 +3,12 @@ import KeychainAccess import WordPressAuthenticator import WordPressKit import Yosemite -import WordPressUI import class Networking.UserAgent import enum Experiments.ABTest import struct Networking.Settings import protocol Experiments.FeatureFlagService import protocol Storage.StorageManagerType -import protocol Networking.ApplicationPasswordUseCase import class Networking.DefaultApplicationPasswordUseCase -import enum Networking.ApplicationPasswordUseCaseError /// Encapsulates all of the interactions with the WordPress Authenticator /// @@ -48,13 +45,8 @@ class AuthenticationManager: Authentication { private let analytics: Analytics - /// Keep strong reference of the use case to check for application password availability if necessary. - private var applicationPasswordUseCase: ApplicationPasswordUseCase? - - /// Keep strong reference of the use case to check for role eligibility if necessary. - private lazy var roleEligibilityUseCase: RoleEligibilityUseCase = { - .init(stores: ServiceLocator.stores) - }() + /// Keeps a reference to the checker + private var postSiteCredentialLoginChecker: PostSiteCredentialLoginChecker? init(storageManager: StorageManagerType = ServiceLocator.storageManager, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, @@ -398,8 +390,7 @@ extension AuthenticationManager: WordPressAuthenticatorDelegate { featureFlagService.isFeatureFlagEnabled(.applicationPasswordAuthenticationForSiteCredentialLogin) { return didAuthenticateUser(to: siteURL, with: siteCredentials, - in: navigationController, - source: source) + in: navigationController) } /// Jetpack is required. Present an error if we don't detect a valid installation for a self-hosted site. @@ -743,7 +734,7 @@ private extension AuthenticationManager { /// The error screen to be displayed when the user tries to enter a site without WooCommerce. /// func noWooUI(for site: Site, - with matcher: ULAccountMatcher, + with matcher: ULAccountMatcher = .init(), navigationController: UINavigationController, onStorePickerDismiss: @escaping () -> Void) -> UIViewController { let viewModel = NoWooErrorViewModel( @@ -807,139 +798,24 @@ private extension AuthenticationManager { return accountMismatchUI(for: site.url, siteCredentials: nil, with: matcher, in: navigationController) } - /// The error screen to be displayed when the user tries to log in with site credentials - /// with application password disabled. - /// - func applicationPasswordDisabledUI(for siteURL: String) -> UIViewController { - let viewModel = ApplicationPasswordDisabledViewModel(siteURL: siteURL) - return ULErrorViewController(viewModel: viewModel) - } - /// Checks if the authenticated user is eligible to use the app and navigates to the home screen. /// func didAuthenticateUser(to siteURL: String, with siteCredentials: WordPressOrgCredentials, - in navigationController: UINavigationController, - source: SignInSource?) { - // check if application password is enabled - guard let applicationPasswordUseCase = try? DefaultApplicationPasswordUseCase( + in navigationController: UINavigationController) { + guard let useCase = try? DefaultApplicationPasswordUseCase( username: siteCredentials.username, password: siteCredentials.password, siteAddress: siteCredentials.siteURL ) else { return assertionFailure("⛔️ Error creating application password use case") } - self.applicationPasswordUseCase = applicationPasswordUseCase - checkApplicationPassword(for: siteURL, - with: applicationPasswordUseCase, - in: navigationController) { [weak self] in - guard let self else { return } - self.checkRoleEligibility(in: navigationController) { [weak self] in - guard let self else { return } - // TODO: check for Woo - // navigates to home screen immediately with a placeholder store ID - self.startStorePicker(with: WooConstants.placeholderStoreID, in: navigationController) - } + let checker = PostSiteCredentialLoginChecker(applicationPasswordUseCase: useCase) + checker.checkEligibility(for: siteURL, from: navigationController) { [weak self] in + // navigates to home screen immediately with a placeholder store ID + self?.startStorePicker(with: WooConstants.placeholderStoreID, in: navigationController) } - } - - func checkApplicationPassword(for siteURL: String, - with useCase: ApplicationPasswordUseCase, - in navigationController: UINavigationController, onSuccess: @escaping () -> Void) { - Task { - do { - let _ = try await useCase.generateNewPassword() - await MainActor.run { - onSuccess() - } - } catch ApplicationPasswordUseCaseError.applicationPasswordsDisabled { - // show application password disabled error - await MainActor.run { - let errorUI = applicationPasswordDisabledUI(for: siteURL) - navigationController.show(errorUI, sender: nil) - } - } catch { - // show generic error - await MainActor.run { - DDLogError("⛔️ Error generating application password: \(error)") - let alert = FancyAlertViewController.makeSiteCredentialLoginAlert( - message: Localization.applicationPasswordError, - retryAction: { [weak self] in - self?.checkApplicationPassword(for: siteURL, with: useCase, in: navigationController, onSuccess: onSuccess) - }, - restartLoginAction: { - ServiceLocator.stores.deauthenticate() - navigationController.popToRootViewController(animated: true) - } - ) - navigationController.present(alert, animated: true) - } - } - } - } - - /// Checks role eligibility for the logged in user with the site address saved in the credentials. - /// Placeholder store ID is used because we are checking for users logging in with site credentials. - /// - func checkRoleEligibility(in navigationController: UINavigationController, onSuccess: @escaping () -> Void) { - roleEligibilityUseCase.checkEligibility(for: WooConstants.placeholderStoreID) { [weak self] result in - guard let self else { return } - switch result { - case .success: - onSuccess() - case .failure(let error): - if case let RoleEligibilityError.insufficientRole(errorInfo) = error { - self.showRoleErrorScreen(for: WooConstants.placeholderStoreID, - errorInfo: errorInfo, - in: navigationController, - onSuccess: onSuccess) - } else { - // show generic error - DDLogError("⛔️ Error checking role eligibility: \(error)") - let alert = FancyAlertViewController.makeSiteCredentialLoginAlert( - message: Localization.roleEligibilityCheckError, - retryAction: { [weak self] in - self?.checkRoleEligibility(in: navigationController, onSuccess: onSuccess) - }, - restartLoginAction: { - ServiceLocator.stores.deauthenticate() - navigationController.popToRootViewController(animated: true) - } - ) - navigationController.present(alert, animated: true) - } - } - } - } - - /// Shows a Role Error page using the provided error information. - /// - func showRoleErrorScreen(for siteID: Int64, - errorInfo: StorageEligibilityErrorInfo, - in navigationController: UINavigationController, - onSuccess: @escaping () -> Void) { - let errorViewModel = RoleErrorViewModel(siteID: siteID, title: errorInfo.name, subtitle: errorInfo.humanizedRoles, useCase: self.roleEligibilityUseCase) - let errorViewController = RoleErrorViewController(viewModel: errorViewModel) - - errorViewModel.onSuccess = onSuccess - errorViewModel.onDeauthenticationRequest = { - ServiceLocator.stores.deauthenticate() - navigationController.popToRootViewController(animated: true) - } - navigationController.show(errorViewController, sender: self) - } -} - -private extension AuthenticationManager { - enum Localization { - static let applicationPasswordError = NSLocalizedString( - "Error fetching application password for your site.", - comment: "Error message displayed when application password cannot be fetched after authentication." - ) - static let roleEligibilityCheckError = NSLocalizedString( - "Error fetching user information.", - comment: "Error message displayed when user information cannot be fetched after authentication." - ) + self.postSiteCredentialLoginChecker = checker } } diff --git a/WooCommerce/Classes/Authentication/PostSiteCredentialLoginChecker.swift b/WooCommerce/Classes/Authentication/PostSiteCredentialLoginChecker.swift new file mode 100644 index 00000000000..f6abe32df0e --- /dev/null +++ b/WooCommerce/Classes/Authentication/PostSiteCredentialLoginChecker.swift @@ -0,0 +1,192 @@ +import Yosemite +import protocol Networking.ApplicationPasswordUseCase +import enum Networking.ApplicationPasswordUseCaseError + +/// Checks if the user is eligible to use the app after logging in with site credentials only. +/// The following checks are made: +/// - Application password availability +/// - Role eligibility +/// - Whether WooCommerce is installed and activated on the logged in site. +/// +final class PostSiteCredentialLoginChecker { + private let stores: StoresManager + private let applicationPasswordUseCase: ApplicationPasswordUseCase + private let roleEligibilityUseCase: RoleEligibilityUseCaseProtocol + + init(applicationPasswordUseCase: ApplicationPasswordUseCase, + roleEligibilityUseCase: RoleEligibilityUseCaseProtocol = RoleEligibilityUseCase(stores: ServiceLocator.stores), + stores: StoresManager = ServiceLocator.stores) { + self.applicationPasswordUseCase = applicationPasswordUseCase + self.roleEligibilityUseCase = roleEligibilityUseCase + self.stores = stores + } + + /// Checks whether the user is eligible to use the app. + /// + func checkEligibility(for siteURL: String, from navigationController: UINavigationController, onSuccess: @escaping () -> Void) { + checkApplicationPassword(for: siteURL, + with: applicationPasswordUseCase, + in: navigationController) { [weak self] in + self?.checkRoleEligibility(in: navigationController) { + self?.checkWooInstallation(for: siteURL, in: navigationController, onSuccess: onSuccess) + } + } + } +} + +private extension PostSiteCredentialLoginChecker { + /// Checks if application password is enabled for the specified site. + /// + func checkApplicationPassword(for siteURL: String, + with useCase: ApplicationPasswordUseCase, + in navigationController: UINavigationController, onSuccess: @escaping () -> Void) { + Task { + do { + let _ = try await useCase.generateNewPassword() + await MainActor.run { + onSuccess() + } + } catch ApplicationPasswordUseCaseError.applicationPasswordsDisabled { + // show application password disabled error + await MainActor.run { + let errorUI = applicationPasswordDisabledUI(for: siteURL) + navigationController.show(errorUI, sender: nil) + } + } catch { + // show generic error + await MainActor.run { + DDLogError("⛔️ Error generating application password: \(error)") + self.showAlert( + message: Localization.applicationPasswordError, + in: navigationController, + onRetry: { [weak self] in + self?.checkApplicationPassword(for: siteURL, with: useCase, in: navigationController, onSuccess: onSuccess) + } + ) + } + } + } + } + + /// Checks role eligibility for the logged in user with the site address saved in the credentials. + /// Placeholder store ID is used because we are checking for users logging in with site credentials. + /// + func checkRoleEligibility(in navigationController: UINavigationController, onSuccess: @escaping () -> Void) { + roleEligibilityUseCase.checkEligibility(for: WooConstants.placeholderStoreID) { [weak self] result in + switch result { + case .success: + onSuccess() + case .failure(let error): + if case let RoleEligibilityError.insufficientRole(errorInfo) = error { + self?.showRoleErrorScreen(for: WooConstants.placeholderStoreID, + errorInfo: errorInfo, + in: navigationController, + onSuccess: onSuccess) + } else { + // show generic error + DDLogError("⛔️ Error checking role eligibility: \(error)") + self?.showAlert( + message: Localization.roleEligibilityCheckError, + in: navigationController, + onRetry: { [weak self] in + self?.checkRoleEligibility(in: navigationController, onSuccess: onSuccess) + } + ) + } + } + } + } + + /// Shows a Role Error page using the provided error information. + /// + func showRoleErrorScreen(for siteID: Int64, + errorInfo: StorageEligibilityErrorInfo, + in navigationController: UINavigationController, + onSuccess: @escaping () -> Void) { + let errorViewModel = RoleErrorViewModel(siteID: siteID, title: errorInfo.name, subtitle: errorInfo.humanizedRoles, useCase: roleEligibilityUseCase) + let errorViewController = RoleErrorViewController(viewModel: errorViewModel) + + errorViewModel.onSuccess = onSuccess + errorViewModel.onDeauthenticationRequest = { [weak self] in + self?.stores.deauthenticate() + navigationController.popToRootViewController(animated: true) + } + navigationController.show(errorViewController, sender: self) + } + + /// Checks if WooCommerce is active on the logged in site. + /// + func checkWooInstallation(for siteURL: String, in navigationController: UINavigationController, + onSuccess: @escaping () -> Void) { + let action = WordPressSiteAction.fetchSiteInfo(siteURL: siteURL) { [weak self] result in + switch result { + case .success(let site): + if site.isWooCommerceActive { + onSuccess() + } else { + self?.showAlert(message: Localization.noWooError, in: navigationController) + } + case .failure(let error): + DDLogError("⛔️ Error checking Woo: \(error)") + // show generic error + self?.showAlert(message: Localization.wooCheckError, in: navigationController, onRetry: { + self?.checkWooInstallation(for: siteURL, in: navigationController, onSuccess: onSuccess) + }) + } + } + stores.dispatch(action) + } + + /// Shows an error alert with a button to restart login and an optional button to retry the failed action. + /// + func showAlert(message: String, + in navigationController: UINavigationController, + onRetry: (() -> Void)? = nil) { + let alert = UIAlertController(title: message, + message: nil, + preferredStyle: .alert) + if let onRetry { + let retryAction = UIAlertAction(title: Localization.retryButton, style: .default) { _ in + onRetry() + } + alert.addAction(retryAction) + } + let restartAction = UIAlertAction(title: Localization.restartLoginButton, style: .cancel) { [weak self] _ in + self?.stores.deauthenticate() + navigationController.popToRootViewController(animated: true) + } + alert.addAction(restartAction) + navigationController.present(alert, animated: true) + } + + /// The error screen to be displayed when the user tries to log in with site credentials + /// with application password disabled. + /// + func applicationPasswordDisabledUI(for siteURL: String) -> UIViewController { + let viewModel = ApplicationPasswordDisabledViewModel(siteURL: siteURL) + return ULErrorViewController(viewModel: viewModel) + } +} + +private extension PostSiteCredentialLoginChecker { + enum Localization { + static let applicationPasswordError = NSLocalizedString( + "Error fetching application password for your site.", + comment: "Error message displayed when application password cannot be fetched after authentication." + ) + static let roleEligibilityCheckError = NSLocalizedString( + "Error fetching user information.", + comment: "Error message displayed when user information cannot be fetched after authentication." + ) + static let noWooError = NSLocalizedString( + "Please install and activate WooCommerce plugin on your site to use the app.", + comment: "Message explaining that the site entered doesn't have WooCommerce installed or activated." + ) + static let wooCheckError = NSLocalizedString( + "Error checking for the WooCommerce plugin.", + comment: "Error message displayed when the WooCommerce plugin detail cannot be fetched after authentication" + ) + static let retryButton = NSLocalizedString("Try Again", comment: "Button to refetch application password for the current site") + static let restartLoginButton = NSLocalizedString("Log In With Another Account", comment: "Button to restart the login flow.") + } +} diff --git a/WooCommerce/Classes/ViewRelated/Fancy Alerts/FancyAlertViewController+UnifiedLogin.swift b/WooCommerce/Classes/ViewRelated/Fancy Alerts/FancyAlertViewController+UnifiedLogin.swift index b55fcd3d4de..36c91520c04 100644 --- a/WooCommerce/Classes/ViewRelated/Fancy Alerts/FancyAlertViewController+UnifiedLogin.swift +++ b/WooCommerce/Classes/ViewRelated/Fancy Alerts/FancyAlertViewController+UnifiedLogin.swift @@ -53,21 +53,6 @@ extension FancyAlertViewController { let controller = FancyAlertViewController.controllerWithConfiguration(configuration: config) return controller } - - static func makeSiteCredentialLoginAlert(message: String, - retryAction: @escaping () -> Void, - restartLoginAction: @escaping () -> Void) -> FancyAlertViewController { - let retryButton = makeRetryButtonConfig(retryAction: retryAction) - let loginButton = makeLoginButtonConfig(loginAction: restartLoginAction) - let config = FancyAlertViewController.Config(titleText: Localization.cannotLogin, - bodyText: message, - headerImage: nil, - dividerPosition: .top, - defaultButton: loginButton, - cancelButton: retryButton) - let controller = FancyAlertViewController.controllerWithConfiguration(configuration: config) - return controller - } } @@ -122,12 +107,6 @@ private extension FancyAlertViewController { comment: "Description of alert for suggestion on how to connect to a WP.com site" + "Presented when a user logs in with an email that does not have access to a WP.com site" ) - static let retryButton = NSLocalizedString("Try Again", comment: "Button to refetch application password for the current site") - static let retryLoginButton = NSLocalizedString("Log In With Another Account", comment: "Button to restart the login flow.") - static let cannotLogin = NSLocalizedString( - "Cannot log in", - comment: "Title of the alert displayed when application password cannot be fetched after authentication" - ) } enum Strings { @@ -145,20 +124,6 @@ private extension FancyAlertViewController { } } - static func makeRetryButtonConfig(retryAction: @escaping () -> Void) -> FancyAlertViewController.Config.ButtonConfig { - return FancyAlertViewController.Config.ButtonConfig(Localization.retryButton) { controller, _ in - controller.dismiss(animated: true) - retryAction() - } - } - - static func makeLoginButtonConfig(loginAction: @escaping () -> Void) -> FancyAlertViewController.Config.ButtonConfig { - return FancyAlertViewController.Config.ButtonConfig(Localization.retryLoginButton) { controller, _ in - controller.dismiss(animated: true) - loginAction() - } - } - static func makeLearnMoreAboutJetpackButtonConfig(analytics: Analytics) -> FancyAlertViewController.Config.ButtonConfig { return FancyAlertViewController.Config.ButtonConfig(Localization.learnMore) { controller, _ in guard let url = URL(string: Strings.whatsJetpackURLString) else { diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index a07b929e6a7..230c35a2693 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1877,6 +1877,8 @@ DEE183F1292E0ED0008818AB /* LoginJetpackSetupInterruptedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE183F0292E0ED0008818AB /* LoginJetpackSetupInterruptedView.swift */; }; DEE6437626D87C4100888A75 /* PrintCustomsFormsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE6437526D87C4100888A75 /* PrintCustomsFormsView.swift */; }; DEE6437826D8DAD900888A75 /* InProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE6437726D8DAD900888A75 /* InProgressView.swift */; }; + DEF13C522963D0B20024A02B /* PostSiteCredentialLoginChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF13C512963D0B20024A02B /* PostSiteCredentialLoginChecker.swift */; }; + DEF13C542963ED4E0024A02B /* PostSiteCredentialLoginCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF13C532963ED4E0024A02B /* PostSiteCredentialLoginCheckerTests.swift */; }; DEF3300C270444070073AE29 /* ShippingLabelSelectedRate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF3300B270444060073AE29 /* ShippingLabelSelectedRate.swift */; }; DEF36DE82898D3CF00178AC2 /* JetpackSetupWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF36DE52898D3CF00178AC2 /* JetpackSetupWebViewModel.swift */; }; DEF36DE92898D3CF00178AC2 /* AuthenticatedWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF36DE62898D3CF00178AC2 /* AuthenticatedWebViewController.swift */; }; @@ -3939,6 +3941,8 @@ DEE183F0292E0ED0008818AB /* LoginJetpackSetupInterruptedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginJetpackSetupInterruptedView.swift; sourceTree = ""; }; DEE6437526D87C4100888A75 /* PrintCustomsFormsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrintCustomsFormsView.swift; sourceTree = ""; }; DEE6437726D8DAD900888A75 /* InProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InProgressView.swift; sourceTree = ""; }; + DEF13C512963D0B20024A02B /* PostSiteCredentialLoginChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSiteCredentialLoginChecker.swift; sourceTree = ""; }; + DEF13C532963ED4E0024A02B /* PostSiteCredentialLoginCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSiteCredentialLoginCheckerTests.swift; sourceTree = ""; }; DEF3300B270444060073AE29 /* ShippingLabelSelectedRate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelSelectedRate.swift; sourceTree = ""; }; DEF36DE52898D3CF00178AC2 /* JetpackSetupWebViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackSetupWebViewModel.swift; sourceTree = ""; }; DEF36DE62898D3CF00178AC2 /* AuthenticatedWebViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewController.swift; sourceTree = ""; }; @@ -6491,6 +6495,7 @@ DE3404E928B4C1D000CF0D97 /* NonAtomicSiteViewModelTests.swift */, DE50295228BF4A8A00551736 /* JetpackConnectionWebViewModelTests.swift */, 020D0BFC2914E92800BB3DCE /* StorePickerCoordinatorTests.swift */, + DEF13C532963ED4E0024A02B /* PostSiteCredentialLoginCheckerTests.swift */, ); path = Authentication; sourceTree = ""; @@ -7113,6 +7118,7 @@ B5D1AFC420BC7B3000DB0E8C /* Epilogue */, 02759B8F28FFA06F00918176 /* Store Creation */, B55D4C0520B6027100D7A50F /* AuthenticationManager.swift */, + DEF13C512963D0B20024A02B /* PostSiteCredentialLoginChecker.swift */, CE16177921B7192A00B82A47 /* AuthenticationConstants.swift */, 027A2E132513124E00DA6ACB /* Keychain+Entries.swift */, 027A2E152513356100DA6ACB /* AppleIDCredentialChecker.swift */, @@ -10961,6 +10967,7 @@ AE77EA5027A47C99006A21BD /* View+AddingDividers.swift in Sources */, 0298430C259351F100979CAE /* ShippingLabelsTopBannerFactory.swift in Sources */, 45B4F0262860BD0A00F3B16E /* WCShipCTAView.swift in Sources */, + DEF13C522963D0B20024A02B /* PostSiteCredentialLoginChecker.swift in Sources */, 020BE74D23B1F5EB007FE54C /* TitleAndTextFieldTableViewCell.swift in Sources */, 023D692E2588BF0900F7DA72 /* ShippingLabelPaperSizeListSelectorCommand.swift in Sources */, CC3B35DD28E5A6EA0036B097 /* ReviewReplyViewModel.swift in Sources */, @@ -11371,6 +11378,7 @@ 453904F523BB8BD5007C4956 /* ProductTaxClassListSelectorDataSourceTests.swift in Sources */, D85DD1E1257F376200861AA8 /* NotWPAccountViewModelTests.swift in Sources */, 020C908424C84652001E2BEB /* ProductListMultiSelectorSearchUICommandTests.swift in Sources */, + DEF13C542963ED4E0024A02B /* PostSiteCredentialLoginCheckerTests.swift in Sources */, EEC2D281292D10520072132E /* SiteCredentialLoginHostingViewControllerTests.swift in Sources */, 746FC23D2200A62B00C3096C /* DateWooTests.swift in Sources */, 31F21B5A263CB41A0035B50A /* MockCardPresentPaymentsStoresManager.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Authentication/PostSiteCredentialLoginCheckerTests.swift b/WooCommerce/WooCommerceTests/Authentication/PostSiteCredentialLoginCheckerTests.swift new file mode 100644 index 00000000000..555de537bbf --- /dev/null +++ b/WooCommerce/WooCommerceTests/Authentication/PostSiteCredentialLoginCheckerTests.swift @@ -0,0 +1,248 @@ +import XCTest +@testable import Yosemite +@testable import Networking +@testable import WooCommerce + +final class PostSiteCredentialLoginCheckerTests: XCTestCase { + private let testURL = "https://test.com" + private var stores: MockStoresManager! + private var navigationController: UINavigationController! + + override func setUp() { + stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true, isWPCom: false)) + navigationController = UINavigationController() + + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIViewController() + window.makeKeyAndVisible() + window.rootViewController = navigationController + super.setUp() + } + + override func tearDown() { + stores = nil + navigationController = nil + super.tearDown() + } + + func test_application_password_disabled_error_is_displayed_when_application_password_is_disabled() { + // Given + let useCase = MockApplicationPasswordUseCase(mockGenerationError: ApplicationPasswordUseCaseError.applicationPasswordsDisabled) + let checker = PostSiteCredentialLoginChecker(applicationPasswordUseCase: useCase) + var isSuccess = false + + // When + checker.checkEligibility(for: testURL, from: navigationController) { + isSuccess = true + } + waitUntil { + self.navigationController.viewControllers.isNotEmpty + } + + // Then + XCTAssertFalse(isSuccess) + XCTAssertTrue(navigationController.topViewController is ULErrorViewController) + } + + func test_error_alert_is_displayed_when_application_password_cannot_be_fetched() { + // Given + let useCase = MockApplicationPasswordUseCase(mockGenerationError: NetworkError.timeout) + let checker = PostSiteCredentialLoginChecker(applicationPasswordUseCase: useCase) + var isSuccess = false + + // When + checker.checkEligibility(for: testURL, from: navigationController) { + isSuccess = true + } + waitUntil { + self.navigationController.presentedViewController != nil + } + + // Then + XCTAssertFalse(isSuccess) + XCTAssertTrue(navigationController.viewControllers.isEmpty) + XCTAssertTrue(navigationController.presentedViewController is UIAlertController) + } + + func test_role_error_screen_is_displayed_when_the_user_is_not_eligible() { + // Given + let applicationPassword = ApplicationPassword(wpOrgUsername: "test", password: .init("secret")) + let appPasswordUseCase = MockApplicationPasswordUseCase(mockGeneratedPassword: applicationPassword) + let roleCheckUseCase = MockRoleEligibilityUseCase() + let errorInfo = StorageEligibilityErrorInfo(name: "Billie Jean", roles: ["skater", "writer"]) + roleCheckUseCase.errorToReturn = .insufficientRole(info: errorInfo) + let checker = PostSiteCredentialLoginChecker(applicationPasswordUseCase: appPasswordUseCase, + roleEligibilityUseCase: roleCheckUseCase) + var isSuccess = false + + // When + checker.checkEligibility(for: testURL, from: navigationController) { + isSuccess = true + } + waitUntil { + self.navigationController.viewControllers.isNotEmpty + } + + // Then + XCTAssertFalse(isSuccess) + XCTAssertTrue(navigationController.topViewController is RoleErrorViewController) + } + + func test_error_alert_is_displayed_when_user_info_cannot_be_fetched() { + // Given + let applicationPassword = ApplicationPassword(wpOrgUsername: "test", password: .init("secret")) + let appPasswordUseCase = MockApplicationPasswordUseCase(mockGeneratedPassword: applicationPassword) + let roleCheckUseCase = MockRoleEligibilityUseCase() + roleCheckUseCase.errorToReturn = .unknown(error: NetworkError.timeout) + let checker = PostSiteCredentialLoginChecker(applicationPasswordUseCase: appPasswordUseCase, + roleEligibilityUseCase: roleCheckUseCase) + var isSuccess = false + + // When + checker.checkEligibility(for: testURL, from: navigationController) { + isSuccess = true + } + waitUntil { + self.navigationController.presentedViewController != nil + } + + // Then + XCTAssertFalse(isSuccess) + XCTAssertTrue(navigationController.presentedViewController is UIAlertController) + } + + func test_onSuccess_is_triggered_when_the_site_has_active_woo() { + // Given + let applicationPassword = ApplicationPassword(wpOrgUsername: "test", password: .init("secret")) + let appPasswordUseCase = MockApplicationPasswordUseCase(mockGeneratedPassword: applicationPassword) + let roleCheckUseCase = MockRoleEligibilityUseCase() + let checker = PostSiteCredentialLoginChecker(applicationPasswordUseCase: appPasswordUseCase, + roleEligibilityUseCase: roleCheckUseCase, + stores: stores) + var isSuccess = false + + // When + stores.whenReceivingAction(ofType: WordPressSiteAction.self) { action in + switch action { + case .fetchSiteInfo(_, let completion): + let site = Site.fake().copy(isWooCommerceActive: true) + completion(.success(site)) + } + } + checker.checkEligibility(for: testURL, from: navigationController) { + isSuccess = true + } + + // Then + waitUntil { + isSuccess == true + } + } + + func test_error_alert_is_displayed_if_the_site_does_not_have_active_woo() { + // Given + let applicationPassword = ApplicationPassword(wpOrgUsername: "test", password: .init("secret")) + let appPasswordUseCase = MockApplicationPasswordUseCase(mockGeneratedPassword: applicationPassword) + let roleCheckUseCase = MockRoleEligibilityUseCase() + let checker = PostSiteCredentialLoginChecker(applicationPasswordUseCase: appPasswordUseCase, + roleEligibilityUseCase: roleCheckUseCase, + stores: stores) + var isSuccess = false + + // When + stores.whenReceivingAction(ofType: WordPressSiteAction.self) { action in + switch action { + case .fetchSiteInfo(_, let completion): + let site = Site.fake().copy(isWooCommerceActive: false) + completion(.success(site)) + } + } + checker.checkEligibility(for: testURL, from: navigationController) { + isSuccess = true + } + waitUntil { + self.navigationController.presentedViewController != nil + } + + // Then + XCTAssertFalse(isSuccess) + XCTAssertTrue(navigationController.presentedViewController is UIAlertController) + } + + func test_error_alert_is_displayed_if_the_site_info_cannot_be_fetched() { + // Given + let applicationPassword = ApplicationPassword(wpOrgUsername: "test", password: .init("secret")) + let appPasswordUseCase = MockApplicationPasswordUseCase(mockGeneratedPassword: applicationPassword) + let roleCheckUseCase = MockRoleEligibilityUseCase() + let checker = PostSiteCredentialLoginChecker(applicationPasswordUseCase: appPasswordUseCase, + roleEligibilityUseCase: roleCheckUseCase, + stores: stores) + var isSuccess = false + + // When + stores.whenReceivingAction(ofType: WordPressSiteAction.self) { action in + switch action { + case .fetchSiteInfo(_, let completion): + let site = Site.fake().copy(isWooCommerceActive: false) + completion(.failure(NetworkError.timeout)) + } + } + checker.checkEligibility(for: testURL, from: navigationController) { + isSuccess = true + } + waitUntil { + self.navigationController.presentedViewController != nil + } + + // Then + XCTAssertFalse(isSuccess) + XCTAssertTrue(navigationController.presentedViewController is UIAlertController) + } +} + +private extension PostSiteCredentialLoginCheckerTests { + struct Constants { + static let eligibleRoles = ["shop_manager", "editor"] + static let ineligibleRoles = ["author", "editor"] + } + + func makeUser(eligible: Bool = false) -> User { + User(localID: 0, siteID: 0, email: "email", username: "username", firstName: "first", lastName: "last", + nickname: "nick", roles: eligible ? Constants.eligibleRoles : Constants.ineligibleRoles) + } +} + +/// MOCK: application password use case +/// +private final class MockApplicationPasswordUseCase: ApplicationPasswordUseCase { + var mockApplicationPassword: ApplicationPassword? + let mockGeneratedPassword: ApplicationPassword? + let mockGenerationError: Error? + let mockDeletionError: Error? + init(mockApplicationPassword: ApplicationPassword? = nil, + mockGeneratedPassword: ApplicationPassword? = nil, + mockGenerationError: Error? = nil, + mockDeletionError: Error? = nil) { + self.mockApplicationPassword = mockApplicationPassword + self.mockGeneratedPassword = mockGeneratedPassword + self.mockGenerationError = mockGenerationError + self.mockDeletionError = mockDeletionError + } + + var applicationPassword: Networking.ApplicationPassword? { + mockApplicationPassword + } + + func generateNewPassword() async throws -> Networking.ApplicationPassword { + if let mockGeneratedPassword { + // Store the newly generated password + mockApplicationPassword = mockGeneratedPassword + return mockGeneratedPassword + } + throw mockGenerationError ?? NetworkError.notFound + } + + func deletePassword() async throws { + throw mockDeletionError ?? NetworkError.notFound + } +} diff --git a/Yosemite/YosemiteTests/Stores/WordPressSiteStore.swift b/Yosemite/YosemiteTests/Stores/WordPressSiteStore.swift index 04e34408344..b4fda88face 100644 --- a/Yosemite/YosemiteTests/Stores/WordPressSiteStore.swift +++ b/Yosemite/YosemiteTests/Stores/WordPressSiteStore.swift @@ -43,7 +43,7 @@ final class WordPressSiteStoreTests: XCTestCase { XCTAssertEqual(site.gmtOffset, 0) XCTAssertEqual(site.adminURL, "https://test.com/wp-admin") XCTAssertEqual(site.loginURL, "https://test.com/wp-login.php") - XCTAssertTrue(site.isWooCommerceActive) + XCTAssertFalse(site.isWooCommerceActive) XCTAssertFalse(site.isJetpackConnected) XCTAssertFalse(site.isJetpackThePluginInstalled) }