diff --git a/Sources/PIALibrary/Account/Data/ClientErrorMapper.swift b/Sources/PIALibrary/Account/Data/ClientErrorMapper.swift index bb86eeee..280614f6 100644 --- a/Sources/PIALibrary/Account/Data/ClientErrorMapper.swift +++ b/Sources/PIALibrary/Account/Data/ClientErrorMapper.swift @@ -42,6 +42,9 @@ struct ClientErrorMapper { case .unableToDecodeData: return .malformedResponseData + + case .unauthorized: + return .unauthorized } } diff --git a/Sources/PIALibrary/Account/Data/DedicatedIPServerMapper.swift b/Sources/PIALibrary/Account/Data/DedicatedIPServerMapper.swift new file mode 100644 index 00000000..9f5683cb --- /dev/null +++ b/Sources/PIALibrary/Account/Data/DedicatedIPServerMapper.swift @@ -0,0 +1,46 @@ + +import Foundation + +class DedicatedIPServerMapper: DedicatedIPServerMapperType { + private let dedicatedIPTokenHandler: DedicatedIPTokenHandlerType + + init(dedicatedIPTokenHandler: DedicatedIPTokenHandlerType) { + self.dedicatedIPTokenHandler = dedicatedIPTokenHandler + } + + func map(dedicatedIps: [DedicatedIPInformation]) -> Result<[Server], ClientError> { + var dipRegions = [Server]() + + for dipServer in dedicatedIps { + let status = DedicatedIPStatus(fromAPIStatus: dipServer.status) + + switch dipServer.status { + case .active: + + guard let firstServer = Client.providers.serverProvider.currentServers.first(where: {$0.regionIdentifier == dipServer.id}) else { + return .failure(ClientError.malformedResponseData) + } + + guard let ip = dipServer.ip, let cn = dipServer.cn, let expirationTime = dipServer.dipExpire else { + return .failure(ClientError.malformedResponseData) + } + + let dipUsername = "dedicated_ip_"+dipServer.dipToken+"_"+String.random(length: 8) + let expiringDate = Date(timeIntervalSince1970: TimeInterval(expirationTime)) + let server = Server.ServerAddressIP(ip: ip, cn: cn, van: false) + + let dipRegion = Server(serial: firstServer.serial, name: firstServer.name, country: firstServer.country, hostname: firstServer.hostname, openVPNAddressesForTCP: [server], openVPNAddressesForUDP: [server], wireGuardAddressesForUDP: [server], iKEv2AddressesForUDP: [server], pingAddress: firstServer.pingAddress, geo: false, meta: nil, dipExpire: expiringDate, dipToken: dipServer.dipToken, dipStatus: status, dipUsername: dipUsername, regionIdentifier: firstServer.regionIdentifier) + + dipRegions.append(dipRegion) + dedicatedIPTokenHandler(dedicatedIp: dipServer, dipUsername: dipUsername) + + default: + + let dipRegion = Server(serial: "", name: "", country: "", hostname: "", openVPNAddressesForTCP: [], openVPNAddressesForUDP: [], wireGuardAddressesForUDP: [], iKEv2AddressesForUDP: [], pingAddress: nil, geo: false, meta: nil, dipExpire: nil, dipToken: nil, dipStatus: status, dipUsername: nil, regionIdentifier: "") + dipRegions.append(dipRegion) + } + } + + return .success(dipRegions) + } +} diff --git a/Sources/PIALibrary/Account/Data/DedicatedIPTokenHandler.swift b/Sources/PIALibrary/Account/Data/DedicatedIPTokenHandler.swift new file mode 100644 index 00000000..759c22d6 --- /dev/null +++ b/Sources/PIALibrary/Account/Data/DedicatedIPTokenHandler.swift @@ -0,0 +1,21 @@ + +import Foundation + +class DedicatedIPTokenHandler: DedicatedIPTokenHandlerType { + private let secureStore: SecureStore + + init(secureStore: SecureStore) { + self.secureStore = secureStore + } + + func callAsFunction(dedicatedIp: DedicatedIPInformation, dipUsername: String) { + if dedicatedIp.isAboutToExpire { + Macros.postNotification(.PIADIPRegionExpiring, [.token : dedicatedIp.dipToken]) + } + + Macros.postNotification(.PIADIPCheckIP, [.token : dedicatedIp.dipToken, .ip : dedicatedIp.ip!]) + + secureStore.setDIPToken(dedicatedIp.dipToken) + secureStore.setPassword(dedicatedIp.ip!, forDipToken: dipUsername) + } +} diff --git a/Sources/PIALibrary/Account/Data/Networking/GetDedicatedIPsRequestConfiguration.swift b/Sources/PIALibrary/Account/Data/Networking/GetDedicatedIPsRequestConfiguration.swift new file mode 100644 index 00000000..acef2d17 --- /dev/null +++ b/Sources/PIALibrary/Account/Data/Networking/GetDedicatedIPsRequestConfiguration.swift @@ -0,0 +1,19 @@ + +import Foundation +import NWHttpConnection + +struct GetDedicatedIPsRequestConfiguration: NetworkRequestConfigurationType { + let networkRequestModule: NetworkRequestModule = .account + let path: RequestAPI.Path = .dedicatedIp + let httpMethod: NWHttpConnection.NWConnectionHTTPMethod = .post + let contentType: NetworkRequestContentType = .json + var inlcudeAuthHeaders: Bool = true + var urlQueryParameters: [String : String]? = nil + let responseDataType: NWDataResponseType = .jsonData + + var body: Data? = nil + var otherHeaders: [String : String]? = nil + + let timeout: TimeInterval = 10 + let requestQueue: DispatchQueue? = DispatchQueue(label: "getDedicatedIPs_request.queue") +} diff --git a/Sources/PIALibrary/Account/Data/Networking/RenewDedicatedIPRequestConfiguration.swift b/Sources/PIALibrary/Account/Data/Networking/RenewDedicatedIPRequestConfiguration.swift new file mode 100644 index 00000000..b22f41a9 --- /dev/null +++ b/Sources/PIALibrary/Account/Data/Networking/RenewDedicatedIPRequestConfiguration.swift @@ -0,0 +1,19 @@ + +import Foundation +import NWHttpConnection + +struct RenewDedicatedIPRequestConfiguration: NetworkRequestConfigurationType { + let networkRequestModule: NetworkRequestModule = .account + let path: RequestAPI.Path = .renewDedicatedIp + let httpMethod: NWHttpConnection.NWConnectionHTTPMethod = .post + let contentType: NetworkRequestContentType = .json + var inlcudeAuthHeaders: Bool = true + var urlQueryParameters: [String : String]? = nil + let responseDataType: NWDataResponseType = .jsonData + + var body: Data? = nil + var otherHeaders: [String : String]? = nil + + let timeout: TimeInterval = 10 + let requestQueue: DispatchQueue? = DispatchQueue(label: "RenewDedicatedIP_request.queue") +} diff --git a/Sources/PIALibrary/Account/Data/SignupInformationDataCoverter.swift b/Sources/PIALibrary/Account/Data/SignupInformationDataCoverter.swift index 96dcf18b..bcad683b 100644 --- a/Sources/PIALibrary/Account/Data/SignupInformationDataCoverter.swift +++ b/Sources/PIALibrary/Account/Data/SignupInformationDataCoverter.swift @@ -6,31 +6,11 @@ class SignupInformationDataCoverter: SignupInformationDataCoverterType { let signupInformation = SignupInformation(store: "apple_app_store", receipt: signup.receipt.base64EncodedString(), email: signup.email, - marketing: stringify(json: signup.marketing), - debug: stringify(json: signup.debug)) + marketing: stringify(json: signup.marketing, prettyPrinted: false), + debug: stringify(json: signup.debug, prettyPrinted: false)) return signupInformation.toData() } - - private func stringify(json: [String: Any]?, prettyPrinted: Bool = false) -> String? { - guard let json else { - return nil - } - - var options: JSONSerialization.WritingOptions = [] - if prettyPrinted { - options = JSONSerialization.WritingOptions.prettyPrinted - } - - do { - let data = try JSONSerialization.data(withJSONObject: json, options: options) - if let string = String(data: data, encoding: String.Encoding.utf8) { - return string - } - } catch { - print(error) - } - - return nil - } } + +extension SignupInformationDataCoverter: JSONToStringCoverterType {} diff --git a/Sources/PIALibrary/Account/Domain/Entities/DedicatedIPInformation.swift b/Sources/PIALibrary/Account/Domain/Entities/DedicatedIPInformation.swift new file mode 100644 index 00000000..595816c3 --- /dev/null +++ b/Sources/PIALibrary/Account/Domain/Entities/DedicatedIPInformation.swift @@ -0,0 +1,46 @@ + +import Foundation + +struct DedicatedIPInformationResult: Codable { + let result: [DedicatedIPInformation] +} + +public struct DedicatedIPInformation: Codable { + enum Status: String, Codable { + case active, expired, invalid, error + } + + let id: String? + let ip: String? + let cn: String? + let groups: [String]? + let dipExpire: Double? + let dipToken: String + let status: DedicatedIPInformation.Status + + enum CodingKeys: String, CodingKey { + case id = "id" + case ip = "ip" + case cn = "cn" + case groups = "groups" + case dipExpire = "dip_expire" + case dipToken = "dip_token" + case status = "status" + } + + //Expiring in 5 days or less + var isAboutToExpire: Bool { + guard let dipExpire, let nextDays = Calendar.current.date(byAdding: .day, value: 5, to: Date()) + else { + return true + } + + let expiringDate = Date(timeIntervalSince1970: TimeInterval(dipExpire)) + return nextDays >= expiringDate + } + + static func makeWith(data: Data) -> [DedicatedIPInformation]? { + let dto = try? JSONDecoder().decode(DedicatedIPInformationResult.self, from: data) + return dto?.result + } +} diff --git a/Sources/PIALibrary/Account/Domain/Interfaces/DedicatedIPServerMapperType.swift b/Sources/PIALibrary/Account/Domain/Interfaces/DedicatedIPServerMapperType.swift new file mode 100644 index 00000000..5ff94398 --- /dev/null +++ b/Sources/PIALibrary/Account/Domain/Interfaces/DedicatedIPServerMapperType.swift @@ -0,0 +1,6 @@ + +import Foundation + +protocol DedicatedIPServerMapperType { + func map(dedicatedIps: [DedicatedIPInformation]) -> Result<[Server], ClientError> +} diff --git a/Sources/PIALibrary/Account/Domain/Interfaces/DedicatedIPTokenHandlerType.swift b/Sources/PIALibrary/Account/Domain/Interfaces/DedicatedIPTokenHandlerType.swift new file mode 100644 index 00000000..fc201fcd --- /dev/null +++ b/Sources/PIALibrary/Account/Domain/Interfaces/DedicatedIPTokenHandlerType.swift @@ -0,0 +1,6 @@ + +import Foundation + +protocol DedicatedIPTokenHandlerType { + func callAsFunction(dedicatedIp: DedicatedIPInformation, dipUsername: String) +} diff --git a/Sources/PIALibrary/Account/Domain/UseCases/GetDedicatedIPsUseCase.swift b/Sources/PIALibrary/Account/Domain/UseCases/GetDedicatedIPsUseCase.swift new file mode 100644 index 00000000..1dcdbd0f --- /dev/null +++ b/Sources/PIALibrary/Account/Domain/UseCases/GetDedicatedIPsUseCase.swift @@ -0,0 +1,75 @@ + +import Foundation + +public protocol GetDedicatedIPsUseCaseType { + typealias Completion = ((Result<[DedicatedIPInformation], NetworkRequestError>) -> Void) + func callAsFunction(dipTokens: [String], completion: @escaping Completion) +} + +class GetDedicatedIPsUseCase: GetDedicatedIPsUseCaseType { + private let networkClient: NetworkRequestClientType + private let refreshAuthTokensChecker: RefreshAuthTokensCheckerType + + init(networkClient: NetworkRequestClientType, refreshAuthTokensChecker: RefreshAuthTokensCheckerType) { + self.networkClient = networkClient + self.refreshAuthTokensChecker = refreshAuthTokensChecker + } + + func callAsFunction(dipTokens: [String], completion: @escaping Completion) { + refreshAuthTokensChecker.refreshIfNeeded { [weak self] error in + guard let self else { return } + if let error { + completion(.failure(error)) + } else { + networkClient.executeRequest(with: makeConfiguration(dipTokens: dipTokens)) { error, dataResponse in + if let error { + self.handleErrorResponse(error, completion: completion) + } else if let dataResponse { + self.handleDataResponse(dataResponse, completion: completion) + } else { + completion(.failure(NetworkRequestError.allConnectionAttemptsFailed())) + } + } + } + } + } + + private func makeConfiguration(dipTokens: [String]) -> GetDedicatedIPsRequestConfiguration { + var configuration = GetDedicatedIPsRequestConfiguration() + + let bodyDataDict = ["tokens": dipTokens] + + if let bodyData = try? JSONEncoder().encode(bodyDataDict) { + configuration.body = bodyData + } + + return configuration + } + + private func handleErrorResponse(_ error: NetworkRequestError, completion: @escaping GetDedicatedIPsUseCaseType.Completion) { + switch error { + case .allConnectionAttemptsFailed(let statusCode): + completion(.failure(statusCode == 401 ? NetworkRequestError.unauthorized : error)) + return + case .connectionError(statusCode: let statusCode, message: _): + completion(.failure(statusCode == 401 ? NetworkRequestError.unauthorized : error)) + return + default: + completion(.failure(error)) + } + } + + private func handleDataResponse(_ dataResponse: NetworkRequestResponseType, completion: @escaping GetDedicatedIPsUseCaseType.Completion) { + guard let dataResponseContent = dataResponse.data else { + completion(.failure(NetworkRequestError.noDataContent)) + return + } + + guard let dto = DedicatedIPInformation.makeWith(data: dataResponseContent) else { + completion(.failure(NetworkRequestError.unableToDecodeDataContent)) + return + } + + completion(.success(dto)) + } +} diff --git a/Sources/PIALibrary/Account/Domain/UseCases/RenewDedicatedIPUseCase.swift b/Sources/PIALibrary/Account/Domain/UseCases/RenewDedicatedIPUseCase.swift new file mode 100644 index 00000000..365b4dc6 --- /dev/null +++ b/Sources/PIALibrary/Account/Domain/UseCases/RenewDedicatedIPUseCase.swift @@ -0,0 +1,60 @@ + +import Foundation + +public protocol RenewDedicatedIPUseCaseType { + typealias Completion = ((Result) -> Void) + func callAsFunction(dipToken: String, completion: @escaping Completion) +} + +class RenewDedicatedIPUseCase: RenewDedicatedIPUseCaseType { + private let networkClient: NetworkRequestClientType + private let refreshAuthTokensChecker: RefreshAuthTokensCheckerType + + init(networkClient: NetworkRequestClientType, refreshAuthTokensChecker: RefreshAuthTokensCheckerType) { + self.networkClient = networkClient + self.refreshAuthTokensChecker = refreshAuthTokensChecker + } + + func callAsFunction(dipToken: String, completion: @escaping Completion) { + refreshAuthTokensChecker.refreshIfNeeded { [weak self] error in + guard let self else { return } + if let error { + completion(.failure(error)) + } else { + networkClient.executeRequest(with: makeConfiguration(dipToken: dipToken)) { error, response in + if let error { + self.handleErrorResponse(error, completion: completion) + return + } + + completion(.success(())) + } + } + } + } + + private func makeConfiguration(dipToken: String) -> RenewDedicatedIPRequestConfiguration { + var configuration = RenewDedicatedIPRequestConfiguration() + + let bodyDataDict = ["token": dipToken] + + if let bodyData = try? JSONEncoder().encode(bodyDataDict) { + configuration.body = bodyData + } + + return configuration + } + + private func handleErrorResponse(_ error: NetworkRequestError, completion: @escaping RenewDedicatedIPUseCaseType.Completion) { + switch error { + case .allConnectionAttemptsFailed(let statusCode): + completion(.failure(statusCode == 401 ? NetworkRequestError.unauthorized : error)) + return + case .connectionError(statusCode: let statusCode, message: let message): + completion(.failure(statusCode == 401 ? NetworkRequestError.unauthorized : error)) + return + default: + completion(.failure(error)) + } + } +} diff --git a/Sources/PIALibrary/Client+Providers.swift b/Sources/PIALibrary/Client+Providers.swift index 7b33925f..fcaa171c 100644 --- a/Sources/PIALibrary/Client+Providers.swift +++ b/Sources/PIALibrary/Client+Providers.swift @@ -32,7 +32,7 @@ extension Client { public var accountProvider: AccountProvider = AccountFactory.makeDefaultAccountProvider() /// Provides methods for handling the available VPN servers. - public var serverProvider: ServerProvider = DefaultServerProvider() + public var serverProvider: ServerProvider = ServerProviderFactory.makeDefaultServerProvider() /// Provides methods for controlling the VPN connection. public var vpnProvider: VPNProvider = DefaultVPNProvider() diff --git a/Sources/PIALibrary/Common/Data/Networking/Entities/NetworkRequestError.swift b/Sources/PIALibrary/Common/Data/Networking/Entities/NetworkRequestError.swift index 2b06875d..7e8c6149 100644 --- a/Sources/PIALibrary/Common/Data/Networking/Entities/NetworkRequestError.swift +++ b/Sources/PIALibrary/Common/Data/Networking/Entities/NetworkRequestError.swift @@ -13,6 +13,7 @@ public enum NetworkRequestError: Error, Equatable { case unableToDecodeDataContent case connectionCompletedWithNoResponse case badReceipt + case unauthorized case unknown(message: String? = nil) case unableToDecodeData diff --git a/Sources/PIALibrary/Mock/MockDedicatedIPServerMapper.swift b/Sources/PIALibrary/Mock/MockDedicatedIPServerMapper.swift new file mode 100644 index 00000000..92674c5c --- /dev/null +++ b/Sources/PIALibrary/Mock/MockDedicatedIPServerMapper.swift @@ -0,0 +1,8 @@ + +import Foundation + +class MockDedicatedIPServerMapper: DedicatedIPServerMapperType { + func map(dedicatedIps: [DedicatedIPInformation]) -> Result<[Server], ClientError> { + return .success([]) + } +} diff --git a/Sources/PIALibrary/Mock/MockGetDedicatedIPsUseCase.swift b/Sources/PIALibrary/Mock/MockGetDedicatedIPsUseCase.swift new file mode 100644 index 00000000..ec9076a9 --- /dev/null +++ b/Sources/PIALibrary/Mock/MockGetDedicatedIPsUseCase.swift @@ -0,0 +1,6 @@ + +import Foundation + +class MockGetDedicatedIPsUseCase: GetDedicatedIPsUseCaseType { + func callAsFunction(dipTokens: [String], completion: @escaping Completion) {} +} diff --git a/Sources/PIALibrary/Mock/MockRenewDedicatedIPUseCase.swift b/Sources/PIALibrary/Mock/MockRenewDedicatedIPUseCase.swift new file mode 100644 index 00000000..05e63a76 --- /dev/null +++ b/Sources/PIALibrary/Mock/MockRenewDedicatedIPUseCase.swift @@ -0,0 +1,6 @@ + +import Foundation + +class MockRenewDedicatedIPUseCase: RenewDedicatedIPUseCaseType { + func callAsFunction(dipToken: String, completion: @escaping Completion) {} +} diff --git a/Sources/PIALibrary/Mock/MockServerProvider.swift b/Sources/PIALibrary/Mock/MockServerProvider.swift index b8f8dd3d..693a1c89 100644 --- a/Sources/PIALibrary/Mock/MockServerProvider.swift +++ b/Sources/PIALibrary/Mock/MockServerProvider.swift @@ -82,7 +82,10 @@ public class MockServerProvider: ServerProvider, DatabaseAccess, WebServicesCons ] let webServices = MockWebServices() - delegate = DefaultServerProvider(webServices: webServices) + delegate = DefaultServerProvider(webServices: webServices, + renewDedicatedIP: MockRenewDedicatedIPUseCase(), + getDedicatedIPs: MockGetDedicatedIPsUseCase(), + dedicatedIPServerMapper: MockDedicatedIPServerMapper()) self.webServices = webServices webServices.serversBundle = { diff --git a/Sources/PIALibrary/Server/CompositionRoot/ServerProviderFactory.swift b/Sources/PIALibrary/Server/CompositionRoot/ServerProviderFactory.swift new file mode 100644 index 00000000..49203108 --- /dev/null +++ b/Sources/PIALibrary/Server/CompositionRoot/ServerProviderFactory.swift @@ -0,0 +1,28 @@ + +import Foundation + +class ServerProviderFactory { + static func makeDefaultServerProvider() -> ServerProvider { + DefaultServerProvider(renewDedicatedIP: makeRenewDedicatedIPUseCase(), + getDedicatedIPs: makeGetDedicatedIPsUseCase(), + dedicatedIPServerMapper: makeDedicatedIPServerMapper()) + } + + static func makeGetDedicatedIPsUseCase() -> GetDedicatedIPsUseCaseType { + GetDedicatedIPsUseCase(networkClient: NetworkRequestFactory.maketNetworkRequestClient(), + refreshAuthTokensChecker: AccountFactory.makeRefreshAuthTokensChecker()) + } + + public static func makeDedicatedIPServerMapper() -> DedicatedIPServerMapperType { + DedicatedIPServerMapper(dedicatedIPTokenHandler: makeDedicatedIPTokenHandler()) + } + + static func makeDedicatedIPTokenHandler() -> DedicatedIPTokenHandlerType { + DedicatedIPTokenHandler(secureStore: Client.database.secure) + } + + public static func makeRenewDedicatedIPUseCase() -> RenewDedicatedIPUseCaseType { + RenewDedicatedIPUseCase(networkClient: NetworkRequestFactory.maketNetworkRequestClient(), + refreshAuthTokensChecker: AccountFactory.makeRefreshAuthTokensChecker()) + } +} diff --git a/Sources/PIALibrary/Server/DefaultServerProvider.swift b/Sources/PIALibrary/Server/DefaultServerProvider.swift index da2de540..2904b8a2 100644 --- a/Sources/PIALibrary/Server/DefaultServerProvider.swift +++ b/Sources/PIALibrary/Server/DefaultServerProvider.swift @@ -27,13 +27,20 @@ import __PIALibraryNative open class DefaultServerProvider: ServerProvider, ConfigurationAccess, DatabaseAccess, PreferencesAccess, WebServicesAccess, WebServicesConsumer { private let customWebServices: WebServices? + private let renewDedicatedIP: RenewDedicatedIPUseCaseType + private let getDedicatedIPs: GetDedicatedIPsUseCaseType + private let dedicatedIPServerMapper: DedicatedIPServerMapperType - init(webServices: WebServices? = nil) { + init(webServices: WebServices? = nil, renewDedicatedIP: RenewDedicatedIPUseCaseType, getDedicatedIPs: GetDedicatedIPsUseCaseType, dedicatedIPServerMapper: DedicatedIPServerMapperType) { if let webServices = webServices { customWebServices = webServices } else { customWebServices = nil } + + self.renewDedicatedIP = renewDedicatedIP + self.getDedicatedIPs = getDedicatedIPs + self.dedicatedIPServerMapper = dedicatedIPServerMapper } // MARK: ServerProvider @@ -149,6 +156,23 @@ open class DefaultServerProvider: ServerProvider, ConfigurationAccess, DatabaseA } } + private func handleDownloadDIPsResponse(_ response: Result<[Server], ClientError>, bundle: ServersBundle, callback: (([Server]?, Error?) -> Void)?) { + switch response { + case .success(let servers): + var allServers = bundle.servers + + for server in servers where !bundle.servers.contains(where: {$0.dipToken == server.dipToken}) { + allServers.append(server) + } + + self.currentServers = allServers + Macros.postNotification(.PIAThemeDidChange) + callback?(currentServers, nil) + case .failure(let clientError): + callback?(currentServers, clientError) + } + } + public func download(_ callback: (([Server]?, Error?) -> Void)?) { webServices.downloadServers { (bundle, error) in guard let bundle = bundle else { @@ -160,6 +184,26 @@ open class DefaultServerProvider: ServerProvider, ConfigurationAccess, DatabaseA } if let tokens = self.accessedDatabase.secure.dipTokens(), !tokens.isEmpty { + self.getDedicatedIPs(dipTokens: tokens) { [weak self] result in + guard let self else { return } + switch result { + case .success(let dedicatedIPServers): + let mapperResult = dedicatedIPServerMapper.map(dedicatedIps: dedicatedIPServers) + handleDownloadDIPsResponse(mapperResult, bundle: bundle, callback: callback) + + case .failure(let error): + let clientError = ClientErrorMapper.map(networkRequestError: error) + if clientError == .unauthorized { + Client.providers.accountProvider.logout(nil) + Macros.postNotification(.PIAUnauthorized) + } else { + callback?(self.currentServers, clientError) + } + } + } + + /* + self.webServices.activateDIPToken(tokens: tokens) { (servers, error) in if error != nil, error as! ClientError == ClientError.unauthorized { @@ -181,12 +225,11 @@ open class DefaultServerProvider: ServerProvider, ConfigurationAccess, DatabaseA self.currentServers = allServers Macros.postNotification(.PIAThemeDidChange) callback?(self.currentServers, error) - } + }*/ } else { self.currentServers = bundle.servers callback?(self.currentServers, error) } - } } @@ -194,38 +237,75 @@ open class DefaultServerProvider: ServerProvider, ConfigurationAccess, DatabaseA guard Client.providers.accountProvider.isLoggedIn else { preconditionFailure() } - webServices.activateDIPToken(tokens: [token]) { (servers, error) in - if let servers = servers, - let first = servers.first, - let status = first.dipStatus { - if !self.currentServers.contains(where: {$0.dipToken == first.dipToken}) && status == .active { - self.currentServers.append(contentsOf: servers) - } - callback?(first, error) - } else { - callback?(nil, error) + + getDedicatedIPs(dipTokens: [token]) { [weak self] result in + guard let self else { return } + switch result { + case .success(let servers): + handleDIPServerResponse(dedicatedIPServerMapper.map(dedicatedIps: servers), callback) + case .failure(let error): + callback?(nil, ClientErrorMapper.map(networkRequestError: error)) + } + } + } + + private func handleDIPServerResponse(_ response: Result<[Server], ClientError>, _ callback: LibraryCallback?) { + guard case .success(let servers) = response else { + guard case .failure(let error) = response else { + callback?(nil, ClientError.unexpectedReply) + return } + + callback?(nil, error) + return } + + guard let first = servers.first, let status = first.dipStatus else { + callback?(nil, ClientError.unexpectedReply) + return + } + + if !self.currentServers.contains(where: {$0.dipToken == first.dipToken}) && status == .active { + self.currentServers.append(contentsOf: servers) + } + + callback?(first, nil) } public func activateDIPTokens(_ tokens: [String], _ callback: LibraryCallback<[Server]>?) { guard Client.providers.accountProvider.isLoggedIn else { preconditionFailure() } - webServices.activateDIPToken(tokens: tokens) { (servers, error) in - if let servers = servers { - for server in servers { - if !self.currentServers.contains(where: {$0.dipToken == server.dipToken}) { - self.currentServers.append(server) - } - } - callback?(servers, error) - } else { - callback?([], error) + + getDedicatedIPs(dipTokens: tokens) { [weak self] result in + guard let self else { return } + switch result { + case .success(let servers): + handleDIPServersResponse(dedicatedIPServerMapper.map(dedicatedIps: servers), callback) + case .failure(let error): + callback?([], ClientErrorMapper.map(networkRequestError: error)) } } } + private func handleDIPServersResponse(_ response: Result<[Server], ClientError>, _ callback: LibraryCallback<[Server]>?) { + guard case .success(let servers) = response else { + guard case .failure(let error) = response else { + callback?(nil, ClientError.unexpectedReply) + return + } + + callback?(nil, error) + return + } + + for server in servers where !self.currentServers.contains(where: {$0.dipToken == server.dipToken}) { + self.currentServers.append(server) + } + + callback?(servers, nil) + } + public func removeDIPToken(_ dipToken: String) { guard Client.providers.accountProvider.isLoggedIn else { preconditionFailure() @@ -238,7 +318,8 @@ open class DefaultServerProvider: ServerProvider, ConfigurationAccess, DatabaseA guard Client.providers.accountProvider.isLoggedIn else { preconditionFailure() } - webServices.handleDIPTokenExpiration(dipToken: dipToken, nil) + + renewDedicatedIP(dipToken: dipToken, completion: { _ in }) } public func find(withIdentifier identifier: String) -> Server? { diff --git a/Sources/PIALibrary/WebServices/DedicatedIP.swift b/Sources/PIALibrary/WebServices/DedicatedIP.swift index 7105de55..1dda8a8a 100644 --- a/Sources/PIALibrary/WebServices/DedicatedIP.swift +++ b/Sources/PIALibrary/WebServices/DedicatedIP.swift @@ -20,7 +20,6 @@ // import Foundation -import account public enum DedicatedIPStatus { @@ -29,7 +28,7 @@ public enum DedicatedIPStatus { case invalid case error - init(fromAPIStatus dipStatus: DedicatedIPInformationResponse.Status) { + init(fromAPIStatus dipStatus: DedicatedIPInformation.Status) { switch dipStatus { case .invalid: self = .invalid diff --git a/Sources/PIALibrary/WebServices/PIAWebServices.swift b/Sources/PIALibrary/WebServices/PIAWebServices.swift index 7f859300..d4569cf2 100644 --- a/Sources/PIALibrary/WebServices/PIAWebServices.swift +++ b/Sources/PIALibrary/WebServices/PIAWebServices.swift @@ -237,64 +237,6 @@ class PIAWebServices: WebServices, ConfigurationAccess { } } - func activateDIPToken(tokens: [String], _ callback: LibraryCallback<[Server]>?) { - self.accountAPI.dedicatedIPs(ipTokens: tokens) { (dedicatedIps, errors) in - if !errors.isEmpty { - callback?([], self.mapDIPError(errors.last)) - return - } - - var dipRegions = [Server]() - for dipServer in dedicatedIps { - - let status = DedicatedIPStatus(fromAPIStatus: dipServer.status) - - switch status { - case .active: - - guard let firstServer = Client.providers.serverProvider.currentServers.first(where: {$0.regionIdentifier == dipServer.id}) else { - callback?([], ClientError.malformedResponseData) - return - } - - guard let ip = dipServer.ip, let cn = dipServer.cn, let expirationTime = dipServer.dip_expire else { - callback?([], ClientError.malformedResponseData) - return - } - - let dipToken = dipServer.dipToken - - let expiringDate = Date(timeIntervalSince1970: TimeInterval(expirationTime)) - let server = Server.ServerAddressIP(ip: ip, cn: cn, van: false) - - if let nextDays = Calendar.current.date(byAdding: .day, value: 5, to: Date()), nextDays >= expiringDate { - //Expiring in 5 days or less - Macros.postNotification(.PIADIPRegionExpiring, [.token : dipToken]) - } - - Macros.postNotification(.PIADIPCheckIP, [.token : dipToken, .ip : ip]) - - let dipUsername = "dedicated_ip_"+dipServer.dipToken+"_"+String.random(length: 8) - - let dipRegion = Server(serial: firstServer.serial, name: firstServer.name, country: firstServer.country, hostname: firstServer.hostname, openVPNAddressesForTCP: [server], openVPNAddressesForUDP: [server], wireGuardAddressesForUDP: [server], iKEv2AddressesForUDP: [server], pingAddress: firstServer.pingAddress, geo: false, meta: nil, dipExpire: expiringDate, dipToken: dipServer.dipToken, dipStatus: status, dipUsername: dipUsername, regionIdentifier: firstServer.regionIdentifier) - - dipRegions.append(dipRegion) - - Client.database.secure.setDIPToken(dipServer.dipToken) - Client.database.secure.setPassword(ip, forDipToken: dipUsername) - - default: - - let dipRegion = Server(serial: "", name: "", country: "", hostname: "", openVPNAddressesForTCP: [], openVPNAddressesForUDP: [], wireGuardAddressesForUDP: [], iKEv2AddressesForUDP: [], pingAddress: nil, geo: false, meta: nil, dipExpire: nil, dipToken: nil, dipStatus: status, dipUsername: nil, regionIdentifier: "") - dipRegions.append(dipRegion) - - } - - } - callback?(dipRegions, nil) - } - } - #if os(iOS) || os(tvOS) func signup(with request: Signup, _ callback: ((Credentials?, Error?) -> Void)?) { var marketingJSON = "" diff --git a/Sources/PIALibrary/WebServices/WebServices.swift b/Sources/PIALibrary/WebServices/WebServices.swift index a284aa2b..490828ea 100644 --- a/Sources/PIALibrary/WebServices/WebServices.swift +++ b/Sources/PIALibrary/WebServices/WebServices.swift @@ -46,8 +46,6 @@ protocol WebServices: class { func handleDIPTokenExpiration(dipToken: String, _ callback: SuccessLibraryCallback?) - func activateDIPToken(tokens: [String], _ callback: LibraryCallback<[Server]>?) - #if os(iOS) || os(tvOS) func signup(with request: Signup, _ callback: LibraryCallback?) #endif diff --git a/Tests/PIALibraryTests/Accounts/GetDedicatedIPsUseCaseTests.swift b/Tests/PIALibraryTests/Accounts/GetDedicatedIPsUseCaseTests.swift new file mode 100644 index 00000000..1aa9338b --- /dev/null +++ b/Tests/PIALibraryTests/Accounts/GetDedicatedIPsUseCaseTests.swift @@ -0,0 +1,224 @@ +// +// GetDedicatedIPsUseCaseTests.swift +// +// +// Created by Said Rehouni on 20/6/24. +// + +import XCTest +@testable import PIALibrary + +final class GetDedicatedIPsUseCaseTests: XCTestCase { + class Fixture { + var networkClientMock = NetworkRequestClientMock() + let refreshAuthTokensCheckerMock = RefreshAuthTokensCheckerMock() + + func stubRequestWithResponse(_ response: NetworkRequestResponseType) { + networkClientMock.executeRequestResponse = response + } + + func stubRequestWithError(_ error: NetworkRequestError) { + networkClientMock.executeRequestError = error + } + } + + var fixture: Fixture! + var sut: GetDedicatedIPsUseCase! + var capturedResult: Result<[DedicatedIPInformation], NetworkRequestError>! + + override func setUp() { + fixture = Fixture() + } + + override func tearDown() { + fixture = nil + sut = nil + capturedResult = nil + } + + private func instantiateSut() { + sut = GetDedicatedIPsUseCase(networkClient: fixture.networkClientMock, + refreshAuthTokensChecker: fixture.refreshAuthTokensCheckerMock) + } + + func test_getDedicatedIPs_completes_with_credentials_succesfully_when_response_is_valid() { + // GIVEN Network client completes with no error and a valid response + let data = """ + { + "result" : [ + { + "id" : "001", + "ip" : "1.1.1", + "cn" : "cn", + "groups" : [], + "dip_expire" : 13123, + "dip_token" : "asdsad", + "status" : "active" + }, + { + "id" : "002", + "ip" : "1.1.2", + "cn" : "cn2", + "groups" : [], + "dip_expire" : 13123, + "dip_token" : "asdsad", + "status" : "active" + }, + ] + } + """.data(using: .utf8) + + fixture.stubRequestWithResponse(NetworkRequestResponseStub(data: data)) + instantiateSut() + + let expectation = expectation(description: "Waiting for get dedicated ips to finish") + + // WHEN get dedicated ips is executed + sut(dipTokens: ["asd", "ffre"]) { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // THEN completes with allConnectionAttemptsFailed error + wait(for: [expectation], timeout: 1.0) + guard case .success(let servers) = capturedResult else { + XCTFail("Expected success got failure") + return + } + + let sortedServers = servers.sorted { ($0.id ?? "") < ($1.id ?? "") } + XCTAssertEqual(sortedServers[0].id, "001") + XCTAssertEqual(sortedServers[0].ip, "1.1.1") + XCTAssertEqual(sortedServers[0].cn, "cn") + XCTAssertEqual(sortedServers[0].groups, []) + XCTAssertEqual(sortedServers[0].dipExpire, 13123) + XCTAssertEqual(sortedServers[0].dipToken, "asdsad") + XCTAssertEqual(sortedServers[0].status, .active) + + XCTAssertEqual(sortedServers[1].id, "002") + XCTAssertEqual(sortedServers[1].ip, "1.1.2") + XCTAssertEqual(sortedServers[1].cn, "cn2") + XCTAssertEqual(sortedServers[1].groups, []) + XCTAssertEqual(sortedServers[1].dipExpire, 13123) + XCTAssertEqual(sortedServers[1].dipToken, "asdsad") + XCTAssertEqual(sortedServers[1].status, .active) + } + + func test_getDedicatedIPs_completes_with_a_allConnectionAttemptsFailed_error_when_there_is_no_error_and_no_response() { + // GIVEN Network client completes with no error and no response + instantiateSut() + + let expectation = expectation(description: "Waiting for get dedicated ips to finish") + + // WHEN get dedicated ips is executed + sut(dipTokens: []) { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // THEN completes with allConnectionAttemptsFailed error + wait(for: [expectation], timeout: 1.0) + guard case .failure(let error) = capturedResult else { + XCTFail("Expected failure got success") + return + } + + XCTAssertEqual(error, .allConnectionAttemptsFailed()) + } + + func test_getDedicatedIPs_completes_with_a_noDataContent_error_when_there_is_response_with_no_data() { + // GIVEN Network client completes with a response with invalid data + fixture.stubRequestWithResponse(NetworkRequestResponseStub(data: nil)) + instantiateSut() + + let expectation = expectation(description: "Waiting for get dedicated ips to finish") + + // WHEN get dedicated ips is executed + sut(dipTokens: []) { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // THEN completes with allConnectionAttemptsFailed error + wait(for: [expectation], timeout: 1.0) + guard case .failure(let error) = capturedResult else { + XCTFail("Expected failure got success") + return + } + + XCTAssertEqual(error, .noDataContent) + } + + func test_getDedicatedIPs_completes_with_a_unableToDecodeDataContent_error_when_there_is_response_with_invalid_data() { + // GIVEN Network client completes with a response with invalid data + let data = "{ \"status\" : \"status\", \"user\" : \"username\", \"pass\" : \"password\"}" + .data(using: .utf8) + + fixture.stubRequestWithResponse(NetworkRequestResponseStub(data: data)) + instantiateSut() + + let expectation = expectation(description: "Waiting for get dedicated ips to finish") + + // WHEN get dedicated ips is executed + sut(dipTokens: []) { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // THEN completes with allConnectionAttemptsFailed error + wait(for: [expectation], timeout: 1.0) + guard case .failure(let error) = capturedResult else { + XCTFail("Expected failure got success") + return + } + + XCTAssertEqual(error, .unableToDecodeDataContent) + } + + func test_getDedicatedIPs_completes_with_an_unauthorized_error_when_there_is_401_status_code() { + // GIVEN Network client completes with an 401 status code error + instantiateSut() + fixture.stubRequestWithError(.connectionError(statusCode: 401, message: "any message")) + let expectation = expectation(description: "Waiting for get dedicated ips to finish") + + // WHEN get dedicated ips is executed + sut(dipTokens: []) { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // THEN completes with unauthorized error + wait(for: [expectation], timeout: 1.0) + guard case .failure(let error) = capturedResult else { + XCTFail("Expected failure got success") + return + } + + XCTAssertEqual(error, .unauthorized) + } + + func test_getDedicatedIPs_creates_valid_networkConfiguration() { + // GIVEN + let expectedBody = try? JSONEncoder().encode(["tokens": ["001", "002"]]) + instantiateSut() + + // WHEN get dedicated ips is executed + sut.callAsFunction(dipTokens: ["001", "002"]) { _ in } + + // THEN + guard let capturedConfiguration = fixture.networkClientMock.executeRequestWithConfiguation as? GetDedicatedIPsRequestConfiguration else { + XCTFail("Expected GetDedicatedIPsRequestConfiguration configuration") + return + } + + XCTAssertEqual(capturedConfiguration.networkRequestModule, .account) + XCTAssertEqual(capturedConfiguration.path, .dedicatedIp) + XCTAssertEqual(capturedConfiguration.httpMethod, .post) + XCTAssertTrue(capturedConfiguration.inlcudeAuthHeaders) + XCTAssertEqual(capturedConfiguration.contentType, .json) + XCTAssertNil(capturedConfiguration.urlQueryParameters) + XCTAssertEqual(capturedConfiguration.responseDataType, .jsonData) + XCTAssertEqual(capturedConfiguration.timeout, 10) + XCTAssertEqual(capturedConfiguration.body?.count, expectedBody?.count) + } +} diff --git a/Tests/PIALibraryTests/Accounts/RenewDedicatedIPUseCaseTests.swift b/Tests/PIALibraryTests/Accounts/RenewDedicatedIPUseCaseTests.swift new file mode 100644 index 00000000..47bc8af7 --- /dev/null +++ b/Tests/PIALibraryTests/Accounts/RenewDedicatedIPUseCaseTests.swift @@ -0,0 +1,129 @@ + +import XCTest +@testable import PIALibrary + +final class RenewDedicatedIPUseCaseTests: XCTestCase { + class Fixture { + var networkClientMock = NetworkRequestClientMock() + let refreshAuthTokensCheckerMock = RefreshAuthTokensCheckerMock() + + func stubRequestWithResponse(_ response: NetworkRequestResponseType) { + networkClientMock.executeRequestResponse = response + } + + func stubRequestWithError(_ error: NetworkRequestError) { + networkClientMock.executeRequestError = error + } + } + + var fixture: Fixture! + var sut: RenewDedicatedIPUseCase! + var capturedResult: Result! + + override func setUp() { + fixture = Fixture() + } + + override func tearDown() { + fixture = nil + sut = nil + capturedResult = nil + } + + private func instantiateSut() { + sut = RenewDedicatedIPUseCase(networkClient: fixture.networkClientMock, + refreshAuthTokensChecker: fixture.refreshAuthTokensCheckerMock) + } + + func test_renewDedicatedIPs_completes_with_success_when_there_is_no_error() { + // GIVEN Network client completes with no error + fixture.stubRequestWithResponse(NetworkRequestResponseStub(data: nil)) + instantiateSut() + + let expectation = expectation(description: "Waiting for renew dedicated IP to finish") + + // WHEN renewDedicatedIPs is executed + sut(dipToken: "dipToken") { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // THEN completes with success + wait(for: [expectation], timeout: 1.0) + guard case .success = capturedResult else { + XCTFail("Expected success, got failure") + return + } + } + + func test_renewDedicatedIPs_completes_with_an_unauthorized_error_when_there_is_401_status_code() { + // GIVEN Network client completes with an 401 status code error + instantiateSut() + fixture.stubRequestWithError(.connectionError(statusCode: 401, message: "any message")) + let expectation = expectation(description: "Waiting for renew dedicated IP to finish") + + // WHEN renewDedicatedIPs is executed + sut(dipToken: "dipToken") { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // THEN completes with unauthorized error + wait(for: [expectation], timeout: 1.0) + guard case .failure(let error) = capturedResult else { + XCTFail("Expected failure got success") + return + } + + XCTAssertEqual(error, .unauthorized) + } + + func test_renewDedicatedIPs_completes_with_an_error_when_network_client_completes_with_a_non_401_error() { + // GIVEN Network client completes with a non 401 error + fixture.stubRequestWithError(.allConnectionAttemptsFailed(statusCode: 404)) + instantiateSut() + + let expectation = expectation(description: "Waiting for renew dedicated IP to finish") + + // WHEN renewDedicatedIPs is executed + sut(dipToken: "dipToken") { [weak self] result in + self?.capturedResult = result + expectation.fulfill() + } + + // THEN completes with allConnectionAttemptsFailed error + wait(for: [expectation], timeout: 1.0) + guard case .failure(let error) = capturedResult else { + XCTFail("Expected failure got success") + return + } + + XCTAssertEqual(error, .allConnectionAttemptsFailed(statusCode: 404)) + } + + func test_renewDedicatedIPs_creates_valid_networkConfiguration() { + // GIVEN + let expectedBody = try? JSONEncoder().encode(["token": "001"]) + instantiateSut() + + // WHEN renewDedicatedIPs is executed + sut.callAsFunction(dipToken: "001") { _ in } + + // THEN + guard let capturedConfiguration = fixture.networkClientMock.executeRequestWithConfiguation as? RenewDedicatedIPRequestConfiguration else { + XCTFail("Expected RenewDedicatedIPRequestConfiguration configuration") + return + } + + XCTAssertEqual(capturedConfiguration.networkRequestModule, .account) + XCTAssertEqual(capturedConfiguration.path, .renewDedicatedIp) + XCTAssertEqual(capturedConfiguration.httpMethod, .post) + XCTAssertTrue(capturedConfiguration.inlcudeAuthHeaders) + XCTAssertEqual(capturedConfiguration.contentType, .json) + XCTAssertNil(capturedConfiguration.urlQueryParameters) + XCTAssertEqual(capturedConfiguration.responseDataType, .jsonData) + XCTAssertEqual(capturedConfiguration.timeout, 10) + XCTAssertEqual(capturedConfiguration.body?.count, expectedBody?.count) + } + +}