diff --git a/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift b/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift index 19f128c412d..84ae16d39ec 100644 --- a/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift +++ b/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift @@ -15,6 +15,12 @@ public struct JetpackSite: Equatable { } } +public enum RequestAuthenticationMode: String { + case appPasswords = "app_passwords" + case appPasswordsWithJetpack = "app_passwords_with_jetpack" // switching to app password for Jetpack sites + case jetpackTunnel = "jetpack_tunnel" +} + extension Alamofire.MultipartFormData: MultipartFormData { public func append(_ data: Data, withName name: String) { self.append(data, withName: name, fileName: nil, mimeType: nil) @@ -24,6 +30,10 @@ extension Alamofire.MultipartFormData: MultipartFormData { /// AlamofireWrapper: Encapsulates all of the Alamofire OP's /// public class AlamofireNetwork: Network { + + /// authentication mode for requests + public private(set) var authenticationMode: RequestAuthenticationMode? + /// Lazy-initialized session manager. Use ensuresSessionManagerIsInitialized=true to avoid race conditions with concurrent requests. private lazy var alamofireSession: Alamofire.Session = { let sessionConfiguration = URLSessionConfiguration.default @@ -88,6 +98,18 @@ public class AlamofireNetwork: Network { } else if ensuresSessionManagerIsInitialized { self.alamofireSession = makeSession(configuration: URLSessionConfiguration.default) } + + let authenticationMode: RequestAuthenticationMode? = { + switch credentials { + case .wporg, .applicationPassword: + return .appPasswords + case .wpcom: + return .jetpackTunnel + case .none: + return nil + } + }() + updateAuthenticationMode(authenticationMode) } public func updateAppPasswordSwitching(enabled: Bool) { @@ -98,6 +120,7 @@ public class AlamofireNetwork: Network { requestConverter = RequestConverter(siteAddress: nil) requestAuthenticator.updateAuthenticator(DefaultRequestAuthenticator(credentials: credentials)) requestAuthenticator.delegate = nil + updateAuthenticationMode(.jetpackTunnel) } } @@ -276,6 +299,7 @@ private extension AlamofireNetwork { requestConverter = RequestConverter(siteAddress: nil) requestAuthenticator.updateAuthenticator(DefaultRequestAuthenticator(credentials: credentials)) requestAuthenticator.delegate = nil + updateAuthenticationMode(.jetpackTunnel) return } requestConverter = RequestConverter(siteAddress: site.siteAddress) @@ -286,8 +310,15 @@ private extension AlamofireNetwork { )) requestAuthenticator.delegate = self errorHandler.resetFailureCount(for: site.siteID) // reset failure count + updateAuthenticationMode(.appPasswordsWithJetpack) } } + + func updateAuthenticationMode(_ mode: RequestAuthenticationMode?) { + DispatchQueue.main.async { [weak self] in + self?.authenticationMode = mode + } + } } // MARK: Helper methods for error handling diff --git a/Modules/Sources/Yosemite/Base/StoresManager.swift b/Modules/Sources/Yosemite/Base/StoresManager.swift index 9a7df27705e..ab3df6538a9 100644 --- a/Modules/Sources/Yosemite/Base/StoresManager.swift +++ b/Modules/Sources/Yosemite/Base/StoresManager.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import enum NetworkingCore.RequestAuthenticationMode /// Abstracts the Stores coordination /// @@ -53,6 +54,9 @@ public protocol StoresManager { /// var isAuthenticatedWithoutWPCom: Bool { get } + /// Authentication mode for network requests + var requestAuthenticationMode: RequestAuthenticationMode? { get } + /// Publishes signal that indicates if the user is currently logged in with credentials. /// var isLoggedInPublisher: AnyPublisher { get } diff --git a/Modules/Sources/Yosemite/Model/Mocks/MockStoresManager.swift b/Modules/Sources/Yosemite/Model/Mocks/MockStoresManager.swift index c947842ec13..be1fb053361 100644 --- a/Modules/Sources/Yosemite/Model/Mocks/MockStoresManager.swift +++ b/Modules/Sources/Yosemite/Model/Mocks/MockStoresManager.swift @@ -1,5 +1,6 @@ import Combine import Storage +import enum NetworkingCore.RequestAuthenticationMode public class MockStoresManager: StoresManager { @@ -223,6 +224,10 @@ public class MockStoresManager: StoresManager { false } + public var requestAuthenticationMode: RequestAuthenticationMode? { + .jetpackTunnel + } + public var needsDefaultStore: Bool { sessionManager.defaultStoreID == nil } diff --git a/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift b/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift index 2b3149cf333..2467b326b8f 100644 --- a/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift +++ b/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift @@ -668,6 +668,142 @@ final class AlamofireNetworkTests: XCTestCase { let networkError = result.1 as? NetworkError XCTAssertEqual(networkError?.errorCode, "failed") } + + // MARK: - Authentication Mode Tests + + func test_authenticationMode_is_appPasswords_for_wporg_credentials() { + // Given + let wporgCredentials = Credentials.wporg(username: "user", password: "pass", siteAddress: "https://example.com") + + // When + let network = AlamofireNetwork(credentials: wporgCredentials, sessionManager: createSessionWithMockURLProtocol()) + + // Then + let expectation = XCTestExpectation(description: "Authentication mode should be set") + DispatchQueue.main.async { + XCTAssertEqual(network.authenticationMode, .appPasswords) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func test_authenticationMode_is_appPasswords_for_applicationPassword_credentials() { + // Given + let appPasswordCredentials = Credentials.applicationPassword(username: "user", password: "pass", siteAddress: "https://example.com") + + // When + let network = AlamofireNetwork(credentials: appPasswordCredentials, sessionManager: createSessionWithMockURLProtocol()) + + // Then + let expectation = XCTestExpectation(description: "Authentication mode should be set") + DispatchQueue.main.async { + XCTAssertEqual(network.authenticationMode, .appPasswords) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func test_authenticationMode_is_jetpackTunnel_for_wpcom_credentials() { + // Given + let wpcomCredentials = createWPComCredentials() + + // When + let network = AlamofireNetwork(credentials: wpcomCredentials, sessionManager: createSessionWithMockURLProtocol()) + + // Then + let expectation = XCTestExpectation(description: "Authentication mode should be set") + DispatchQueue.main.async { + XCTAssertEqual(network.authenticationMode, .jetpackTunnel) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func test_authenticationMode_is_nil_for_no_credentials() { + // When + let network = AlamofireNetwork(credentials: nil, sessionManager: createSessionWithMockURLProtocol()) + + // Then + let expectation = XCTestExpectation(description: "Authentication mode should be set") + DispatchQueue.main.async { + XCTAssertNil(network.authenticationMode) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func test_authenticationMode_changes_to_appPasswordsWithJetpack_when_app_password_switching_enabled() { + // Given + let siteID: Int64 = 123 + let wpcomCredentials = createWPComCredentials() + let network = createNetworkWithSelectedSite(siteID: siteID, credentials: wpcomCredentials, userDefaults: userDefaults) + + // When - Enable app password switching + network.updateAppPasswordSwitching(enabled: true) + + // Then + let expectation = XCTestExpectation(description: "Authentication mode should change") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(network.authenticationMode, .appPasswordsWithJetpack) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func test_authenticationMode_reverts_to_jetpackTunnel_when_app_password_switching_disabled() { + // Given + let siteID: Int64 = 456 + let wpcomCredentials = createWPComCredentials() + let network = createNetworkWithSelectedSite(siteID: siteID, credentials: wpcomCredentials, userDefaults: userDefaults) + + // When - Enable then disable app password switching + network.updateAppPasswordSwitching(enabled: true) + network.updateAppPasswordSwitching(enabled: false) + + // Then + let expectation = XCTestExpectation(description: "Authentication mode should revert") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(network.authenticationMode, .jetpackTunnel) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func test_authenticationMode_remains_jetpackTunnel_when_site_flagged_as_unsupported() { + // Given + let siteID: Int64 = 789 + let wpcomCredentials = createWPComCredentials() + userDefaults.applicationPasswordUnsupportedList = [String(siteID): Date()] + let network = createNetworkWithSelectedSite(siteID: siteID, credentials: wpcomCredentials, userDefaults: userDefaults) + + // When - Enable app password switching for an unsupported site + network.updateAppPasswordSwitching(enabled: true) + + // Then + let expectation = XCTestExpectation(description: "Authentication mode should remain jetpackTunnel") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(network.authenticationMode, .jetpackTunnel) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func test_authenticationMode_does_not_change_for_non_wpcom_credentials() { + // Given + let wporgCredentials = Credentials.wporg(username: "user", password: "pass", siteAddress: "https://example.com") + let network = AlamofireNetwork(credentials: wporgCredentials, sessionManager: createSessionWithMockURLProtocol()) + + // When - Try to enable app password switching (should have no effect) + network.updateAppPasswordSwitching(enabled: true) + + // Then + let expectation = XCTestExpectation(description: "Authentication mode should remain unchanged") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(network.authenticationMode, .appPasswords) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } } private extension AlamofireNetworkTests { diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift b/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift index 02cc24ce27e..b0ec57f3a79 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift @@ -147,12 +147,19 @@ extension WooAnalyticsEvent { pageNumber: Int, filters: FilterOrderListViewModel.Filters?, totalCompletedOrders: Int?) -> WooAnalyticsEvent { + let requestAuthMode: String = { + guard let authState = ServiceLocator.stores.requestAuthenticationMode else { + return "none" + } + return authState.rawValue + }() let properties: [String: WooAnalyticsEventPropertyType?] = [ "status": (filters?.orderStatus ?? []).map { $0.rawValue }.joined(separator: ","), "page_number": Int64(pageNumber), "total_duration": Double(totalDuration), "date_range": filters?.dateRange?.analyticsDescription ?? String(), - "total_completed_orders": totalCompletedOrders + "total_completed_orders": totalCompletedOrders, + "request_type": requestAuthMode ] return WooAnalyticsEvent(statName: .ordersListLoaded, properties: properties.compactMapValues { $0 }) } diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index 7b5fbe82627..5280e9f6249 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -3,11 +3,16 @@ import Yosemite import Networking import Storage import Combine +import enum NetworkingCore.RequestAuthenticationMode // MARK: - AuthenticatedState // class AuthenticatedState: StoresManagerState { + var requestAuthenticationMode: RequestAuthenticationMode? { + network.authenticationMode + } + /// Dispatcher: Glues all of the Stores! /// private let dispatcher = Dispatcher() diff --git a/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift b/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift index 2f5eb8c814f..9f9cf0bd1e9 100644 --- a/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift +++ b/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift @@ -8,6 +8,7 @@ import KeychainAccess import class WidgetKit.WidgetCenter import Experiments import WordPressAuthenticator +import enum NetworkingCore.RequestAuthenticationMode // MARK: - DefaultStoresManager // @@ -79,6 +80,14 @@ class DefaultStoresManager: StoresManager { return state is AuthenticatedState } + /// Authentication mode for network requests + var requestAuthenticationMode: RequestAuthenticationMode? { + guard let state = state as? AuthenticatedState else { + return nil + } + return state.requestAuthenticationMode + } + /// Indicates if the StoresManager is currently authenticated with site credentials only. /// var isAuthenticatedWithoutWPCom: Bool {