From 9ac4c687d8685fd783f5a6c936b6f6bda19b50b8 Mon Sep 17 00:00:00 2001 From: Jon Petersson Date: Wed, 13 Dec 2023 15:40:01 +0100 Subject: [PATCH] Store API access method settings --- ios/MullvadSettings/SettingsManager.swift | 36 +---- ios/MullvadSettings/SettingsStore.swift | 1 + ios/MullvadVPN.xcodeproj/project.pbxproj | 15 +- .../AccessMethodRepository.swift | 109 +++++++++++---- .../APIAccessMethodsTests.swift | 132 ++++++++++++++++++ 5 files changed, 231 insertions(+), 62 deletions(-) create mode 100644 ios/MullvadVPNTests/APIAccessMethodsTests.swift diff --git a/ios/MullvadSettings/SettingsManager.swift b/ios/MullvadSettings/SettingsManager.swift index 8de7cc1a9db3..98588679a2c2 100644 --- a/ios/MullvadSettings/SettingsManager.swift +++ b/ios/MullvadSettings/SettingsManager.swift @@ -25,7 +25,7 @@ public enum SettingsManager { ) /// Alternative store used for tests. - internal static var unitTestStore: SettingsStore? + public static var unitTestStore: SettingsStore? public static var store: SettingsStore { if let unitTestStore { return unitTestStore } @@ -140,41 +140,19 @@ public enum SettingsManager { try store.write(data, for: .deviceState) } - /// Removes all legacy settings, device state and tunnel settings but keeps the last used - /// account number stored. + /// Removes all legacy settings, device state, tunnel settings and API access methods but keeps + /// the last used account number stored. public static func resetStore(completely: Bool = false) { logger.debug("Reset store.") - do { - try store.delete(key: .deviceState) - } catch { - if (error as? KeychainError) != .itemNotFound { - logger.error(error: error, message: "Failed to delete device state.") - } - } - - do { - try store.delete(key: .settings) - } catch { - if (error as? KeychainError) != .itemNotFound { - logger.error(error: error, message: "Failed to delete settings.") - } - } - - if completely { - do { - try store.delete(key: .lastUsedAccount) - } catch { - if (error as? KeychainError) != .itemNotFound { - logger.error(error: error, message: "Failed to delete last used account.") - } - } + let keys = completely ? SettingsKey.allCases : [.settings, .deviceState, .apiAccessMethods] + keys.forEach { key in do { - try store.delete(key: .shouldWipeSettings) + try store.delete(key: key) } catch { if (error as? KeychainError) != .itemNotFound { - logger.error(error: error, message: "Failed to delete should wipe settings.") + logger.error(error: error, message: "Failed to delete \(key.rawValue).") } } } diff --git a/ios/MullvadSettings/SettingsStore.swift b/ios/MullvadSettings/SettingsStore.swift index 4609ce8d39c4..f922e3292cba 100644 --- a/ios/MullvadSettings/SettingsStore.swift +++ b/ios/MullvadSettings/SettingsStore.swift @@ -11,6 +11,7 @@ import Foundation public enum SettingsKey: String, CaseIterable { case settings = "Settings" case deviceState = "DeviceState" + case apiAccessMethods = "ApiAccessMethods" case lastUsedAccount = "LastUsedAccount" case shouldWipeSettings = "ShouldWipeSettings" } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index c1800a1b5e5d..63022d950777 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -120,7 +120,6 @@ 5846227726E22A7C0035F7C2 /* StorePaymentManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846227626E22A7C0035F7C2 /* StorePaymentManagerDelegate.swift */; }; 584D26C4270C855B004EA533 /* PreferencesDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584D26C3270C855A004EA533 /* PreferencesDataSource.swift */; }; 584D26C6270C8741004EA533 /* SettingsDNSTextCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584D26C5270C8741004EA533 /* SettingsDNSTextCell.swift */; }; - 584EBDBD2747C98F00A0C9FD /* NSAttributedString+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */; }; 5859A55529CD9DD900F66591 /* changes.txt in Resources */ = {isa = PBXBuildFile; fileRef = 5859A55429CD9DD800F66591 /* changes.txt */; }; 585A02E92A4B283000C6CAFF /* TCPUnsafeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585A02E82A4B283000C6CAFF /* TCPUnsafeListener.swift */; }; 585A02EB2A4B285800C6CAFF /* UDPConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585A02EA2A4B285800C6CAFF /* UDPConnection.swift */; }; @@ -517,6 +516,11 @@ 7A6F2FAF2AFE36E7006D0856 /* PreferencesInfoButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FAE2AFE36E7006D0856 /* PreferencesInfoButtonItem.swift */; }; 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */; }; 7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */; }; + 7A83A0C62B29A750008B5CE7 /* APIAccessMethodsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */; }; + 7A83A0C72B29A831008B5CE7 /* AccessMethodRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A02B0E064E00CCBBA1 /* AccessMethodRepository.swift */; }; + 7A83A0C82B29A851008B5CE7 /* PersistentAccessMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D962B04E0AC00E7CDD7 /* PersistentAccessMethod.swift */; }; + 7A83A0C92B29AA8C008B5CE7 /* AccessMethodRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF875A2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift */; }; + 7A83A0CA2B29AAB5008B5CE7 /* ShadowsocksCipher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DFF7D92B02862E00F864E0 /* ShadowsocksCipher.swift */; }; 7A83C3FF2A55B72E00DFB83A /* MullvadVPNApp.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 7A83C3FE2A55B72E00DFB83A /* MullvadVPNApp.xctestplan */; }; 7A83C4022A57FAA800DFB83A /* SettingsDNSInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A83C4012A57FAA800DFB83A /* SettingsDNSInfoCell.swift */; }; 7A88DCD82A8FABBE00D2FF0E /* Routing.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A88DCCE2A8FABBE00D2FF0E /* Routing.framework */; }; @@ -1651,6 +1655,7 @@ 7A6F2FAE2AFE36E7006D0856 /* PreferencesInfoButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesInfoButtonItem.swift; sourceTree = ""; }; 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = ""; }; 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootConfiguration.swift; sourceTree = ""; }; + 7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIAccessMethodsTests.swift; sourceTree = ""; }; 7A83C3FE2A55B72E00DFB83A /* MullvadVPNApp.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = MullvadVPNApp.xctestplan; sourceTree = ""; }; 7A83C4002A55B81A00DFB83A /* MullvadVPNCI.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MullvadVPNCI.xctestplan; sourceTree = ""; }; 7A83C4012A57FAA800DFB83A /* SettingsDNSInfoCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDNSInfoCell.swift; sourceTree = ""; }; @@ -2676,6 +2681,7 @@ 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */, A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */, A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */, + 7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */, A900E9BD2ACC654100C95F67 /* APIProxy+Stubs.swift */, A9EC20E72A5D3A8C0040D56E /* CoordinatesTests.swift */, 5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */, @@ -2689,7 +2695,6 @@ F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */, A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */, 58C3FA652A38549D006A450A /* MockFileCache.swift */, - F09D04BA2AE95396003D4F89 /* URLSessionStub.swift */, F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */, F09D04B62AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift */, F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */, @@ -2702,6 +2707,7 @@ A9A5F9A12ACB003D0083449F /* TunnelManagerTests.swift */, A9E0317B2ACBFC7E0095D843 /* TunnelStore+Stubs.swift */, A9E031792ACB0AE70095D843 /* UIApplication+Stubs.swift */, + F09D04BA2AE95396003D4F89 /* URLSessionStub.swift */, 58165EBD2A262CBB00688EAD /* WgKeyRotationTests.swift */, F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */, ); @@ -4359,6 +4365,7 @@ A9A5F9E12ACB05160083449F /* AddressCacheTracker.swift in Sources */, A900E9BC2ACC609200C95F67 /* DevicesProxy+Stubs.swift in Sources */, A9A5F9E22ACB05160083449F /* BackgroundTask.swift in Sources */, + 7A83A0C92B29AA8C008B5CE7 /* AccessMethodRepositoryProtocol.swift in Sources */, A9A5F9E32ACB05160083449F /* AccountDataThrottling.swift in Sources */, A9A5F9E42ACB05160083449F /* AppPreferences.swift in Sources */, A9A5F9E52ACB05160083449F /* CustomDateComponentsFormatting.swift in Sources */, @@ -4380,6 +4387,7 @@ A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */, A9A5F9F32ACB05160083449F /* AccountExpirySystemNotificationProvider.swift in Sources */, A9A5F9F52ACB05160083449F /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, + 7A83A0CA2B29AAB5008B5CE7 /* ShadowsocksCipher.swift in Sources */, F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */, F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */, A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */, @@ -4399,6 +4407,7 @@ A9A5FA022ACB05160083449F /* RelayCacheTracker.swift in Sources */, A9A5FA032ACB05160083449F /* SimulatorTunnelInfo.swift in Sources */, A9A5FA042ACB05160083449F /* SimulatorTunnelProvider.swift in Sources */, + 7A83A0C72B29A831008B5CE7 /* AccessMethodRepository.swift in Sources */, A9A5FA052ACB05160083449F /* SimulatorTunnelProviderHost.swift in Sources */, A900E9C02ACC661900C95F67 /* AccessTokenManager+Stubs.swift in Sources */, A9E0317A2ACB0AE70095D843 /* UIApplication+Stubs.swift in Sources */, @@ -4424,6 +4433,7 @@ F09D04B52AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift in Sources */, 58BE4B9D2B18A85B007EA1D3 /* NSAttributedString+Markdown.swift in Sources */, A9A5FA172ACB05160083449F /* SendTunnelProviderMessageOperation.swift in Sources */, + 7A83A0C62B29A750008B5CE7 /* APIAccessMethodsTests.swift in Sources */, A9A5FA182ACB05160083449F /* SetAccountOperation.swift in Sources */, A9A5FA192ACB05160083449F /* StartTunnelOperation.swift in Sources */, A9A5FA1A2ACB05160083449F /* StopTunnelOperation.swift in Sources */, @@ -4457,6 +4467,7 @@ A9A5FA302ACB05160083449F /* InputTextFormatterTests.swift in Sources */, F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */, A9A5FA312ACB05160083449F /* MockFileCache.swift in Sources */, + 7A83A0C82B29A851008B5CE7 /* PersistentAccessMethod.swift in Sources */, A9A5FA322ACB05160083449F /* RelayCacheTests.swift in Sources */, A9A5FA332ACB05160083449F /* RelaySelectorTests.swift in Sources */, 58DFF7D32B02570000F864E0 /* MarkdownStylingOptions.swift in Sources */, diff --git a/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepository.swift b/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepository.swift index c0c0ec86076b..e7c5266be327 100644 --- a/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepository.swift +++ b/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepository.swift @@ -2,68 +2,115 @@ // AccessMethodRepository.swift // MullvadVPN // -// Created by pronebird on 22/11/2023. +// Created by Jon Petersson on 12/12/2023. // Copyright © 2023 Mullvad VPN AB. All rights reserved. // import Combine import Foundation +import MullvadSettings class AccessMethodRepository: AccessMethodRepositoryProtocol { - private var memoryStore: [PersistentAccessMethod] { - didSet { - publisher.send(memoryStore) - } - } - let publisher: PassthroughSubject<[PersistentAccessMethod], Never> = .init() static let shared = AccessMethodRepository() + private var defaultDirectMethod: PersistentAccessMethod { + PersistentAccessMethod( + id: UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!, + name: "", + isEnabled: true, + proxyConfiguration: .direct + ) + } + + private var defaultBridgesMethod: PersistentAccessMethod { + PersistentAccessMethod( + id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!, + name: "", + isEnabled: true, + proxyConfiguration: .bridges + ) + } + init() { - memoryStore = [ - PersistentAccessMethod( - id: UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!, - name: "", - isEnabled: true, - proxyConfiguration: .direct - ), - PersistentAccessMethod( - id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!, - name: "", - isEnabled: true, - proxyConfiguration: .bridges - ), - ] + add([defaultDirectMethod, defaultBridgesMethod]) } func add(_ method: PersistentAccessMethod) { - guard !memoryStore.contains(where: { $0.id == method.id }) else { return } + add([method]) + } + + func add(_ methods: [PersistentAccessMethod]) { + var storedMethods = fetchAll() + + methods.forEach { method in + guard !storedMethods.contains(where: { $0.id == method.id }) else { return } + storedMethods.append(method) + } - memoryStore.append(method) + do { + try writeApiAccessMethods(storedMethods) + } catch { + print("Could not add access method(s): \(methods) \nError: \(error)") + } } func update(_ method: PersistentAccessMethod) { - guard let index = memoryStore.firstIndex(where: { $0.id == method.id }) else { return } + var methods = fetchAll() + + guard let index = methods.firstIndex(where: { $0.id == method.id }) else { return } + methods[index] = method - memoryStore[index] = method + do { + try writeApiAccessMethods(methods) + } catch { + print("Could not update access method: \(method) \nError: \(error)") + } } func delete(id: UUID) { - guard let index = memoryStore.firstIndex(where: { $0.id == id }) else { return } + var methods = fetchAll() + guard let index = methods.firstIndex(where: { $0.id == id }) else { return } - // Prevent removing methods that have static UUIDs and always present. - let permanentMethod = memoryStore[index] + // Prevent removing methods that have static UUIDs and are always present. + let permanentMethod = methods[index] if !permanentMethod.kind.isPermanent { - memoryStore.remove(at: index) + methods.remove(at: index) + } + + do { + try writeApiAccessMethods(methods) + } catch { + print("Could not delete access method with id: \(id) \nError: \(error)") } } func fetch(by id: UUID) -> PersistentAccessMethod? { - memoryStore.first { $0.id == id } + fetchAll().first { $0.id == id } } func fetchAll() -> [PersistentAccessMethod] { - memoryStore + (try? readApiAccessMethods()) ?? [] + } + + private func readApiAccessMethods() throws -> [PersistentAccessMethod] { + let parser = makeParser() + let data = try SettingsManager.store.read(key: .apiAccessMethods) + + return try parser.parseUnversionedPayload(as: [PersistentAccessMethod].self, from: data) + } + + private func writeApiAccessMethods(_ accessMethods: [PersistentAccessMethod]) throws { + let parser = makeParser() + let data = try parser.produceUnversionedPayload(accessMethods) + + try SettingsManager.store.write(data, for: .apiAccessMethods) + + publisher.send(accessMethods) + } + + private func makeParser() -> SettingsParser { + SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) } } diff --git a/ios/MullvadVPNTests/APIAccessMethodsTests.swift b/ios/MullvadVPNTests/APIAccessMethodsTests.swift new file mode 100644 index 000000000000..0d65cb632918 --- /dev/null +++ b/ios/MullvadVPNTests/APIAccessMethodsTests.swift @@ -0,0 +1,132 @@ +// +// APIAccessMethodsTests.swift +// MullvadVPNTests +// +// Created by Jon Petersson on 2023-12-13. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import XCTest + +final class APIAccessMethodsTests: XCTestCase { + static let store = InMemorySettingsStore() + + override class func setUp() { + SettingsManager.unitTestStore = store + } + + override class func tearDown() { + SettingsManager.unitTestStore = nil + } + + override func tearDownWithError() throws { + let repository = AccessMethodRepository.shared + repository.fetchAll().forEach { + repository.delete(id: $0.id) + } + } + + func testDefaultAccessMethodsExist() throws { + let storedMethods = AccessMethodRepository.shared.fetchAll() + + let hasDirectMethod = storedMethods.contains { method in + method.kind == .direct + } + + let hasBridgesMethod = storedMethods.contains { method in + method.kind == .bridges + } + + XCTAssertEqual(storedMethods.count, 2) + XCTAssertTrue(hasDirectMethod && hasBridgesMethod) + } + + func testAddingSocks5AccessMethod() throws { + let uuid = UUID() + let methodToStore = socks5AccessMethod(with: uuid) + + AccessMethodRepository.shared.add(methodToStore) + let storedMethod = AccessMethodRepository.shared.fetch(by: uuid) + + XCTAssertEqual(methodToStore.id, storedMethod?.id) + } + + func testAddingShadowSocksAccessMethod() throws { + let uuid = UUID() + let methodToStore = shadowsocksAccessMethod(with: uuid) + + AccessMethodRepository.shared.add(methodToStore) + let storedMethod = AccessMethodRepository.shared.fetch(by: uuid) + + XCTAssertEqual(methodToStore.id, storedMethod?.id) + } + + func testAddingDuplicateAccessMethodDoesNothing() throws { + let methodToStore = socks5AccessMethod(with: UUID()) + + AccessMethodRepository.shared.add(methodToStore) + AccessMethodRepository.shared.add(methodToStore) + let storedMethods = AccessMethodRepository.shared.fetchAll() + + // Account for .direct and .bridges that are always added by default. + XCTAssertEqual(storedMethods.count, 3) + } + + func testUpdatingAccessMethod() throws { + let uuid = UUID() + var methodToStore = socks5AccessMethod(with: uuid) + + AccessMethodRepository.shared.add(methodToStore) + + let newName = "Renamed method" + methodToStore.name = newName + + AccessMethodRepository.shared.update(methodToStore) + + let storedMethod = AccessMethodRepository.shared.fetch(by: uuid) + + XCTAssertEqual(storedMethod?.name, newName) + } + + func testDeletingAccessMethod() throws { + let uuid = UUID() + let methodToStore = socks5AccessMethod(with: uuid) + + AccessMethodRepository.shared.add(methodToStore) + AccessMethodRepository.shared.delete(id: uuid) + + let storedMethod = AccessMethodRepository.shared.fetch(by: uuid) + + XCTAssertNil(storedMethod) + } +} + +extension APIAccessMethodsTests { + private func socks5AccessMethod(with uuid: UUID) -> PersistentAccessMethod { + PersistentAccessMethod( + id: uuid, + name: "Method", + isEnabled: true, + proxyConfiguration: .socks5(PersistentProxyConfiguration.SocksConfiguration( + server: .ipv4(.any), + port: 1, + authentication: .noAuthentication + )) + ) + } + + private func shadowsocksAccessMethod(with uuid: UUID) -> PersistentAccessMethod { + PersistentAccessMethod( + id: uuid, + name: "Method", + isEnabled: true, + proxyConfiguration: .shadowsocks(PersistentProxyConfiguration.ShadowsocksConfiguration( + server: .ipv4(.any), + port: 1, + password: "Password", + cipher: .default + )) + ) + } +}