From 96fdbad7b9f1dd0164281acfa7afada105c5b335 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Sun, 9 Aug 2020 04:27:38 -0700 Subject: [PATCH] Add anonymous instance, lots of refactoring --- Development Assets/DevelopmentModels.swift | 44 ++-- Metatext.xcodeproj/project.pbxproj | 6 - Shared/Localizations/Localizable.strings | 3 +- Shared/MetatextApp.swift | 21 +- Shared/Model/AppEnvironment.swift | 3 - Shared/Model/Defaults.swift | 24 -- .../Networking/Mastodon API/MastodonAPI.swift | 21 -- Shared/Services/AuthenticationService.swift | 215 +++++++----------- Shared/Services/IdentitiesService.swift | 41 +++- Shared/Services/IdentityService.swift | 27 ++- Shared/View Models/AddIdentityViewModel.swift | 45 +++- Shared/View Models/RootViewModel.swift | 2 +- Shared/Views/AddIdentityView.swift | 7 +- .../Services/AuthenticationServiceTests.swift | 29 +-- .../AddIdentityViewModelTests.swift | 34 +-- Tests/View Models/RootViewModelTests.swift | 9 +- 16 files changed, 235 insertions(+), 296 deletions(-) delete mode 100644 Shared/Model/Defaults.swift diff --git a/Development Assets/DevelopmentModels.swift b/Development Assets/DevelopmentModels.swift index fd6d22d..5fa6236 100644 --- a/Development Assets/DevelopmentModels.swift +++ b/Development Assets/DevelopmentModels.swift @@ -23,18 +23,6 @@ let developmentKeychainService: KeychainServiceType = { return keychainService }() -extension Defaults { - static func fresh() -> Defaults { Defaults(userDefaults: MockUserDefaults()) } - - static let development: Defaults = { - let preferences = Defaults.fresh() - - // Do future setup here - - return preferences - }() -} - extension Account { static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8)) } @@ -69,30 +57,26 @@ extension IdentityDatabase { } extension AppEnvironment { - static func fresh( - URLSessionConfiguration: URLSessionConfiguration = .stubbing, - identityDatabase: IdentityDatabase = .fresh(), - defaults: Defaults = .fresh(), - keychainService: KeychainServiceType = freshKeychainService(), - webAuthSessionType: WebAuthSessionType.Type = SuccessfulMockWebAuthSession.self) -> AppEnvironment { - AppEnvironment( - URLSessionConfiguration: URLSessionConfiguration, - identityDatabase: identityDatabase, - defaults: defaults, - keychainService: keychainService, - webAuthSessionType: webAuthSessionType) - } - static let development = AppEnvironment( URLSessionConfiguration: .stubbing, - identityDatabase: .development, - defaults: .development, - keychainService: developmentKeychainService, webAuthSessionType: SuccessfulMockWebAuthSession.self) } extension IdentitiesService { - static let development = IdentitiesService(environment: .development) + static func fresh( + identityDatabase: IdentityDatabase = .fresh(), + keychainService: KeychainServiceType = MockKeychainService(), + environment: AppEnvironment = .development) -> IdentitiesService { + IdentitiesService( + identityDatabase: identityDatabase, + keychainService: keychainService, + environment: environment) + } + + static let development = IdentitiesService( + identityDatabase: .development, + keychainService: developmentKeychainService, + environment: .development) } extension IdentityService { diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index f013ecc..01ee3cf 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -57,8 +57,6 @@ D052BBC724D749C800A80A7A /* RootViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC624D749C800A80A7A /* RootViewModelTests.swift */; }; D052BBCA24D74C9200A80A7A /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */; }; D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */; }; - D052BBCF24D750C000A80A7A /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCE24D750C000A80A7A /* Defaults.swift */; }; - D052BBD024D750C000A80A7A /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCE24D750C000A80A7A /* Defaults.swift */; }; D052BBD124D750CA00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; }; D052BBD224D750CB00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; }; D065F53924D37E5100741304 /* CombineExpectations in Frameworks */ = {isa = PBXBuildFile; productRef = D065F53824D37E5100741304 /* CombineExpectations */; }; @@ -211,7 +209,6 @@ D052BBC624D749C800A80A7A /* RootViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModelTests.swift; sourceTree = ""; }; D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = ""; }; D052BBCC24D750A100A80A7A /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; - D052BBCE24D750C000A80A7A /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; }; D065F53A24D3B33A00741304 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -446,7 +443,6 @@ D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */, D052BBCC24D750A100A80A7A /* AppEnvironment.swift */, D0ED1BD624CF94B200B4899C /* Application.swift */, - D052BBCE24D750C000A80A7A /* Defaults.swift */, D0666A5324C6C3E500F3F04B /* Emoji.swift */, D0666A4A24C6C37700F3F04B /* Identity.swift */, D0666A4D24C6C39600F3F04B /* Instance.swift */, @@ -778,7 +774,6 @@ D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */, D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */, D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */, - D052BBCF24D750C000A80A7A /* Defaults.swift in Sources */, D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */, D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */, D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */, @@ -859,7 +854,6 @@ D0159FA624DE98F600E78478 /* NSMutableAttributedString+Extensions.swift in Sources */, D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */, D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */, - D052BBD024D750C000A80A7A /* Defaults.swift in Sources */, D0ED1BE424CFA84400B4899C /* MastodonError.swift in Sources */, D0666A6424C6DC6C00F3F04B /* AppAuthorization.swift in Sources */, D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */, diff --git a/Shared/Localizations/Localizable.strings b/Shared/Localizations/Localizable.strings index 7b08a45..2ed5036 100644 --- a/Shared/Localizations/Localizable.strings +++ b/Shared/Localizations/Localizable.strings @@ -1,7 +1,8 @@ // Copyright © 2020 Metabolist. All rights reserved. -"go" = "Go"; "add-identity.instance-url" = "Instance URL"; +"add-identity.log-in" = "Log in"; +"add-identity.browse-anonymously" = "Browse anonymously"; "oauth.error.code-not-found" = "OAuth error: code not found"; "secondary-navigation.manage-accounts" = "Manage Accounts"; "secondary-navigation.preferences" = "Preferences"; diff --git a/Shared/MetatextApp.swift b/Shared/MetatextApp.swift index 0365340..5d2f3ff 100644 --- a/Shared/MetatextApp.swift +++ b/Shared/MetatextApp.swift @@ -4,28 +4,27 @@ import SwiftUI @main struct MetatextApp: App { - private let environment: AppEnvironment + private let identityDatabase: IdentityDatabase + private let keychainServive = KeychainService(serviceName: "com.metabolist.metatext") + private let environment = AppEnvironment( + URLSessionConfiguration: .default, + webAuthSessionType: WebAuthSession.self) init() { - let identityDatabase: IdentityDatabase - do { try identityDatabase = IdentityDatabase() } catch { fatalError("Failed to initialize identity database") } - - environment = AppEnvironment( - URLSessionConfiguration: .default, - identityDatabase: identityDatabase, - defaults: Defaults(userDefaults: .standard), - keychainService: KeychainService(serviceName: Self.keychainServiceName), - webAuthSessionType: WebAuthSession.self) } var body: some Scene { WindowGroup { - RootView(viewModel: RootViewModel(identitiesService: IdentitiesService(environment: environment))) + RootView( + viewModel: RootViewModel(identitiesService: IdentitiesService( + identityDatabase: identityDatabase, + keychainService: keychainServive, + environment: environment))) } } } diff --git a/Shared/Model/AppEnvironment.swift b/Shared/Model/AppEnvironment.swift index ff9f7c0..3869141 100644 --- a/Shared/Model/AppEnvironment.swift +++ b/Shared/Model/AppEnvironment.swift @@ -4,8 +4,5 @@ import Foundation struct AppEnvironment { let URLSessionConfiguration: URLSessionConfiguration - let identityDatabase: IdentityDatabase - let defaults: Defaults - let keychainService: KeychainServiceType let webAuthSessionType: WebAuthSessionType.Type } diff --git a/Shared/Model/Defaults.swift b/Shared/Model/Defaults.swift deleted file mode 100644 index 1237c92..0000000 --- a/Shared/Model/Defaults.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import Foundation - -class Defaults { - private let userDefaults: UserDefaults - - init(userDefaults: UserDefaults) { - self.userDefaults = userDefaults - } -} - -extension Defaults { - enum Item: String { - case recentIdentityID = "recent-identity-id" - } -} - -extension Defaults { - subscript(index: Defaults.Item) -> T? { - get { userDefaults.value(forKey: index.rawValue) as? T } - set { userDefaults.set(newValue, forKey: index.rawValue) } - } -} diff --git a/Shared/Networking/Mastodon API/MastodonAPI.swift b/Shared/Networking/Mastodon API/MastodonAPI.swift index e30698e..28cbba9 100644 --- a/Shared/Networking/Mastodon API/MastodonAPI.swift +++ b/Shared/Networking/Mastodon API/MastodonAPI.swift @@ -4,25 +4,4 @@ import Foundation struct MastodonAPI { static let dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - - struct OAuth { - static let clientName = "Metatext" - static let scopes = "read write follow push" - static let codeCallbackQueryItemName = "code" - static let grantType = "authorization_code" - static let callbackURLScheme = "metatext" - } - - enum OAuthError { - case codeNotFound - } -} - -extension MastodonAPI.OAuthError: LocalizedError { - var errorDescription: String? { - switch self { - case .codeNotFound: - return NSLocalizedString("oauth.error.code-not-found", comment: "") - } - } } diff --git a/Shared/Services/AuthenticationService.swift b/Shared/Services/AuthenticationService.swift index e1dc2fe..39862be 100644 --- a/Shared/Services/AuthenticationService.swift +++ b/Shared/Services/AuthenticationService.swift @@ -4,168 +4,109 @@ import Foundation import Combine struct AuthenticationService { - private let environment: AppEnvironment private let networkClient: MastodonClient + private let webAuthSessionType: WebAuthSessionType.Type private let webAuthSessionContextProvider = WebAuthSessionContextProvider() init(environment: AppEnvironment) { - self.environment = environment networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) + webAuthSessionType = environment.webAuthSessionType } } extension AuthenticationService { - func authenticate(instanceURL: URL) -> AnyPublisher { - let identityID = UUID() - let redirectURL: URL - - do { - redirectURL = try identityID.uuidString.url(scheme: MastodonAPI.OAuth.callbackURLScheme) - } catch { - return Fail(error: error).eraseToAnyPublisher() - } - - return authorizeApp( - identityID: identityID, - instanceURL: instanceURL, - redirectURL: redirectURL, - keychainService: environment.keychainService) - .authenticationURL(instanceURL: instanceURL, redirectURL: redirectURL) - .authenticate( - webAuthSessionType: environment.webAuthSessionType, - contextProvider: webAuthSessionContextProvider, - callbackURLScheme: MastodonAPI.OAuth.callbackURLScheme) - .extractCode() - .requestAccessToken( - networkClient: networkClient, - identityID: identityID, - instanceURL: instanceURL, - redirectURL: redirectURL) - .createIdentity(id: identityID, instanceURL: instanceURL, environment: environment) - } -} - -private extension AuthenticationService { - func authorizeApp( - identityID: UUID, - instanceURL: URL, - redirectURL: URL, - keychainService: KeychainServiceType) -> AnyPublisher { + func authorizeApp(instanceURL: URL) -> AnyPublisher { let endpoint = AppAuthorizationEndpoint.apps( - clientName: MastodonAPI.OAuth.clientName, - redirectURI: redirectURL.absoluteString, - scopes: MastodonAPI.OAuth.scopes, - website: nil) + clientName: OAuth.clientName, + redirectURI: OAuth.callbackURL.absoluteString, + scopes: OAuth.scopes, + website: OAuth.website) let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil) return networkClient.request(target) - .tryMap { - let secretsService = SecretsService(identityID: identityID, keychainService: keychainService) - try secretsService.set($0.clientId, forItem: .clientID) - try secretsService.set($0.clientSecret, forItem: .clientSecret) + } - return $0 + func authenticate(instanceURL: URL, appAuthorization: AppAuthorization) -> AnyPublisher { + guard let authorizationURL = authorizationURL( + instanceURL: instanceURL, + clientID: appAuthorization.clientId) else { + return Fail(error: URLError(.badURL)).eraseToAnyPublisher() + } + + return webAuthSessionType.publisher( + url: authorizationURL, + callbackURLScheme: OAuth.callbackURLScheme, + presentationContextProvider: webAuthSessionContextProvider) + .tryCatch { error -> AnyPublisher in + if (error as? WebAuthSessionError)?.code == .canceledLogin { + return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() + } + + throw error + } + .compactMap { $0 } + .tryMap { url -> String in + guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems, + let code = queryItems.first(where: { + $0.name == OAuth.codeCallbackQueryItemName + })?.value + else { throw OAuthError.codeNotFound } + + return code + } + .flatMap { code -> AnyPublisher in + let endpoint = AccessTokenEndpoint.oauthToken( + clientID: appAuthorization.clientId, + clientSecret: appAuthorization.clientSecret, + code: code, + grantType: OAuth.grantType, + scopes: OAuth.scopes, + redirectURI: OAuth.callbackURL.absoluteString) + let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil) + + return networkClient.request(target) } .eraseToAnyPublisher() } } -private extension Publisher where Output == AppAuthorization { - func authenticationURL(instanceURL: URL, redirectURL: URL) -> AnyPublisher<(AppAuthorization, URL), Error> { - tryMap { appAuthorization in - guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else { - throw URLError(.badURL) - } +private extension AuthenticationService { + struct OAuth { + static let clientName = "Metatext" + static let scopes = "read write follow push" + static let codeCallbackQueryItemName = "code" + static let grantType = "authorization_code" + static let callbackURLScheme = "metatext" + static let callbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")! + static let website = URL(string: "https://metabolist.com/metatext")! + } - authorizationURLComponents.path = "/oauth/authorize" - authorizationURLComponents.queryItems = [ - "client_id": appAuthorization.clientId, - "scope": MastodonAPI.OAuth.scopes, - "response_type": "code", - "redirect_uri": redirectURL.absoluteString - ].map { URLQueryItem(name: $0, value: $1) } + enum OAuthError { + case codeNotFound + } - guard let authorizationURL = authorizationURLComponents.url else { - throw URLError(.badURL) - } - - return (appAuthorization, authorizationURL) + private func authorizationURL(instanceURL: URL, clientID: String) -> URL? { + guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else { + return nil } - .mapError { $0 as Error } - .eraseToAnyPublisher() + + authorizationURLComponents.path = "/oauth/authorize" + authorizationURLComponents.queryItems = [ + "client_id": clientID, + "scope": OAuth.scopes, + "response_type": "code", + "redirect_uri": OAuth.callbackURL.absoluteString + ].map { URLQueryItem(name: $0, value: $1) } + + return authorizationURLComponents.url } } -private extension Publisher where Output == (AppAuthorization, URL), Failure == Error { - func authenticate( - webAuthSessionType: WebAuthSessionType.Type, - contextProvider: WebAuthSessionContextProvider, - callbackURLScheme: String) -> AnyPublisher<(AppAuthorization, URL), Error> { - flatMap { appAuthorization, url in - webAuthSessionType.publisher( - url: url, - callbackURLScheme: callbackURLScheme, - presentationContextProvider: contextProvider) - .tryCatch { error -> AnyPublisher in - if (error as? WebAuthSessionError)?.code == .canceledLogin { - return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() - } - - throw error - } - .compactMap { $0 } - .map { (appAuthorization, $0) } +extension AuthenticationService.OAuthError: LocalizedError { + var errorDescription: String? { + switch self { + case .codeNotFound: + return NSLocalizedString("oauth.error.code-not-found", comment: "") } - .eraseToAnyPublisher() - } -} - -private extension Publisher where Output == (AppAuthorization, URL) { - func extractCode() -> AnyPublisher<(AppAuthorization, String), Error> { - tryMap { appAuthorization, url -> (AppAuthorization, String) in - guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems, - let code = queryItems.first(where: { $0.name == MastodonAPI.OAuth.codeCallbackQueryItemName })?.value - else { throw MastodonAPI.OAuthError.codeNotFound } - - return (appAuthorization, code) - } - .eraseToAnyPublisher() - } -} - -private extension Publisher where Output == (AppAuthorization, String), Failure == Error { - func requestAccessToken( - networkClient: HTTPClient, - identityID: UUID, - instanceURL: URL, - redirectURL: URL) -> AnyPublisher { - flatMap { appAuthorization, code -> AnyPublisher in - let endpoint = AccessTokenEndpoint.oauthToken( - clientID: appAuthorization.clientId, - clientSecret: appAuthorization.clientSecret, - code: code, - grantType: MastodonAPI.OAuth.grantType, - scopes: MastodonAPI.OAuth.scopes, - redirectURI: redirectURL.absoluteString) - let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil) - - return networkClient.request(target) - } - .eraseToAnyPublisher() - } -} - -private extension Publisher where Output == AccessToken { - func createIdentity(id: UUID, instanceURL: URL, environment: AppEnvironment) -> AnyPublisher { - tryMap { accessToken -> (UUID, URL) in - let secretsService = SecretsService(identityID: id, keychainService: environment.keychainService) - - try secretsService.set(accessToken.accessToken, forItem: .accessToken) - - return (id, instanceURL) - } - .flatMap(environment.identityDatabase.createIdentity) - .map { id } - .eraseToAnyPublisher() } } diff --git a/Shared/Services/IdentitiesService.swift b/Shared/Services/IdentitiesService.swift index 9b57df8..dfc1177 100644 --- a/Shared/Services/IdentitiesService.swift +++ b/Shared/Services/IdentitiesService.swift @@ -6,12 +6,16 @@ import Combine class IdentitiesService { @Published var mostRecentlyUsedIdentityID: UUID? + private let identityDatabase: IdentityDatabase + private let keychainService: KeychainServiceType private let environment: AppEnvironment - init(environment: AppEnvironment) { + init(identityDatabase: IdentityDatabase, keychainService: KeychainServiceType, environment: AppEnvironment) { + self.identityDatabase = identityDatabase + self.keychainService = keychainService self.environment = environment - environment.identityDatabase.mostRecentlyUsedIdentityIDObservation() + identityDatabase.mostRecentlyUsedIdentityIDObservation() .replaceError(with: nil) .assign(to: &$mostRecentlyUsedIdentityID) } @@ -19,20 +23,43 @@ class IdentitiesService { extension IdentitiesService { func identityService(id: UUID) throws -> IdentityService { - try IdentityService(identityID: id, environment: environment) + try IdentityService(identityID: id, + identityDatabase: identityDatabase, + keychainService: keychainService, + environment: environment) } - func authenticationService() -> AuthenticationService { - AuthenticationService(environment: environment) + func createIdentity(id: UUID, instanceURL: URL) -> AnyPublisher { + identityDatabase.createIdentity(id: id, url: instanceURL) + } + + func authorizeIdentity(id: UUID, instanceURL: URL) -> AnyPublisher { + let secretsService = SecretsService(identityID: id, keychainService: keychainService) + let authenticationService = AuthenticationService(environment: environment) + + return authenticationService.authorizeApp(instanceURL: instanceURL) + .tryMap { appAuthorization -> (URL, AppAuthorization) in + try secretsService.set(appAuthorization.clientId, forItem: .clientID) + try secretsService.set(appAuthorization.clientSecret, forItem: .clientSecret) + + return (instanceURL, appAuthorization) + } + .flatMap(authenticationService.authenticate(instanceURL:appAuthorization:)) + .tryMap { accessToken -> Void in + try secretsService.set(accessToken.accessToken, forItem: .accessToken) + + return () + } + .eraseToAnyPublisher() } func deleteIdentity(id: UUID) -> AnyPublisher { - environment.identityDatabase.deleteIdentity(id: id) + identityDatabase.deleteIdentity(id: id) .continuingIfWeakReferenceIsStillAlive(to: self) .tryMap { _, welf -> Void in try SecretsService( identityID: id, - keychainService: welf.environment.keychainService) + keychainService: welf.keychainService) .deleteAllItems() return () diff --git a/Shared/Services/IdentityService.swift b/Shared/Services/IdentityService.swift index efd6169..4dba831 100644 --- a/Shared/Services/IdentityService.swift +++ b/Shared/Services/IdentityService.swift @@ -7,15 +7,20 @@ class IdentityService { @Published private(set) var identity: Identity let observationErrors: AnyPublisher - private let networkClient: MastodonClient + private let identityDatabase: IdentityDatabase private let environment: AppEnvironment + private let networkClient: MastodonClient private let observationErrorsInput = PassthroughSubject() - init(identityID: UUID, environment: AppEnvironment) throws { + init(identityID: UUID, + identityDatabase: IdentityDatabase, + keychainService: KeychainServiceType, + environment: AppEnvironment) throws { + self.identityDatabase = identityDatabase self.environment = environment observationErrors = observationErrorsInput.eraseToAnyPublisher() - let observation = environment.identityDatabase.identityObservation(id: identityID).share() + let observation = identityDatabase.identityObservation(id: identityID).share() var initialIdentity: Identity? _ = observation.first().sink( @@ -29,7 +34,7 @@ class IdentityService { networkClient.instanceURL = identity.url networkClient.accessToken = try SecretsService( identityID: identityID, - keychainService: environment.keychainService) + keychainService: keychainService) .item(.accessToken) observation.catch { [weak self] error -> Empty in @@ -45,14 +50,14 @@ extension IdentityService { var isAuthorized: Bool { networkClient.accessToken != nil } func updateLastUse() -> AnyPublisher { - environment.identityDatabase.updateLastUsedAt(identityID: identity.id) + identityDatabase.updateLastUsedAt(identityID: identity.id) } func verifyCredentials() -> AnyPublisher { networkClient.request(AccountEndpoint.verifyCredentials) .continuingIfWeakReferenceIsStillAlive(to: self) .map { ($0, $1.identity.id) } - .flatMap(environment.identityDatabase.updateAccount) + .flatMap(identityDatabase.updateAccount) .eraseToAnyPublisher() } @@ -60,7 +65,7 @@ extension IdentityService { networkClient.request(PreferencesEndpoint.preferences) .continuingIfWeakReferenceIsStillAlive(to: self) .map { ($1.identity.preferences.updated(from: $0), $1.identity.id) } - .flatMap(environment.identityDatabase.updatePreferences) + .flatMap(identityDatabase.updatePreferences) .eraseToAnyPublisher() } @@ -68,19 +73,19 @@ extension IdentityService { networkClient.request(InstanceEndpoint.instance) .continuingIfWeakReferenceIsStillAlive(to: self) .map { ($0, $1.identity.id) } - .flatMap(environment.identityDatabase.updateInstance) + .flatMap(identityDatabase.updateInstance) .eraseToAnyPublisher() } func identitiesObservation() -> AnyPublisher<[Identity], Error> { - environment.identityDatabase.identitiesObservation() + identityDatabase.identitiesObservation() } func recentIdentitiesObservation() -> AnyPublisher<[Identity], Error> { - environment.identityDatabase.recentIdentitiesObservation(excluding: identity.id) + identityDatabase.recentIdentitiesObservation(excluding: identity.id) } func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher { - environment.identityDatabase.updatePreferences(preferences, forIdentityID: identity.id) + identityDatabase.updatePreferences(preferences, forIdentityID: identity.id) } } diff --git a/Shared/View Models/AddIdentityViewModel.swift b/Shared/View Models/AddIdentityViewModel.swift index 68f3d73..56645e0 100644 --- a/Shared/View Models/AddIdentityViewModel.swift +++ b/Shared/View Models/AddIdentityViewModel.swift @@ -9,19 +9,31 @@ class AddIdentityViewModel: ObservableObject { @Published private(set) var loading = false let addedIdentityID: AnyPublisher - private let authenticationService: AuthenticationService + private let identitiesService: IdentitiesService private let addedIdentityIDInput = PassthroughSubject() private var cancellables = Set() - init(authenticationService: AuthenticationService) { - self.authenticationService = authenticationService + init(identitiesService: IdentitiesService) { + self.identitiesService = identitiesService addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher() } - func goTapped() { - Just(urlFieldText) - .tryMap { try $0.url() } - .flatMap(authenticationService.authenticate(instanceURL:)) + func logInTapped() { + let identityID = UUID() + let instanceURL: URL + + do { + try instanceURL = urlFieldText.url() + } catch { + alertItem = AlertItem(error: error) + + return + } + + identitiesService.authorizeIdentity(id: identityID, instanceURL: instanceURL) + .map { (identityID, instanceURL) } + .flatMap(identitiesService.createIdentity(id:instanceURL:)) + .map { identityID } .assignErrorsToAlertItem(to: \.alertItem, on: self) .receive(on: RunLoop.main) .handleEvents( @@ -30,4 +42,23 @@ class AddIdentityViewModel: ObservableObject { .sink(receiveValue: addedIdentityIDInput.send) .store(in: &cancellables) } + + func browseAnonymouslyTapped() { + let identityID = UUID() + let instanceURL: URL + + do { + try instanceURL = urlFieldText.url() + } catch { + alertItem = AlertItem(error: error) + + return + } + + identitiesService.createIdentity(id: identityID, instanceURL: instanceURL) + .map { identityID } + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink(receiveValue: addedIdentityIDInput.send) + .store(in: &cancellables) + } } diff --git a/Shared/View Models/RootViewModel.swift b/Shared/View Models/RootViewModel.swift index 093c338..48d4d4a 100644 --- a/Shared/View Models/RootViewModel.swift +++ b/Shared/View Models/RootViewModel.swift @@ -52,6 +52,6 @@ extension RootViewModel { } func addIdentityViewModel() -> AddIdentityViewModel { - AddIdentityViewModel(authenticationService: identitiesService.authenticationService()) + AddIdentityViewModel(identitiesService: identitiesService) } } diff --git a/Shared/Views/AddIdentityView.swift b/Shared/Views/AddIdentityView.swift index 888252b..836ccd5 100644 --- a/Shared/Views/AddIdentityView.swift +++ b/Shared/Views/AddIdentityView.swift @@ -21,12 +21,13 @@ struct AddIdentityView: View { if viewModel.loading { ProgressView() } else { - Button( - action: viewModel.goTapped, - label: { Text("go") }) + Button("add-identity.log-in", + action: viewModel.logInTapped) } } .frame(maxWidth: .infinity, alignment: .center) + Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped) + .frame(maxWidth: .infinity, alignment: .center) #if os(macOS) Spacer() #endif diff --git a/Tests/Services/AuthenticationServiceTests.swift b/Tests/Services/AuthenticationServiceTests.swift index d76567f..a8e5143 100644 --- a/Tests/Services/AuthenticationServiceTests.swift +++ b/Tests/Services/AuthenticationServiceTests.swift @@ -6,26 +6,21 @@ import CombineExpectations @testable import Metatext class AuthenticationServiceTests: XCTestCase { - func testAddIdentity() throws { - let environment = AppEnvironment.fresh() - let sut = AuthenticationService(environment: environment) + func testAuthentication() throws { + let sut = AuthenticationService(environment: .development) let instanceURL = URL(string: "https://mastodon.social")! - let addedIDRecorder = sut.authenticate(instanceURL: instanceURL).record() + let appAuthorizationRecorder = sut.authorizeApp(instanceURL: instanceURL).record() + let appAuthorization = try wait(for: appAuthorizationRecorder.next(), timeout: 1) - let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1) - let identityRecorder = environment.identityDatabase.identityObservation(id: addedIdentityID).record() - let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1) + XCTAssertEqual(appAuthorization.clientId, "AUTHORIZATION_CLIENT_ID_STUB_VALUE") + XCTAssertEqual(appAuthorization.clientSecret, "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE") - XCTAssertEqual(addedIdentity.id, addedIdentityID) - XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) + let accessTokenRecorder = sut.authenticate( + instanceURL: instanceURL, + appAuthorization: appAuthorization) + .record() + let accessToken = try wait(for: accessTokenRecorder.next(), timeout: 1) - let secretsService = SecretsService(identityID: addedIdentity.id, keychainService: environment.keychainService) - - XCTAssertEqual( - try secretsService.item(.clientID) as String?, "AUTHORIZATION_CLIENT_ID_STUB_VALUE") - XCTAssertEqual( - try secretsService.item(.clientSecret) as String?, "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE") - XCTAssertEqual( - try secretsService.item(.accessToken) as String?, "ACCESS_TOKEN_STUB_VALUE") + XCTAssertEqual(accessToken.accessToken, "ACCESS_TOKEN_STUB_VALUE") } } diff --git a/Tests/View Models/AddIdentityViewModelTests.swift b/Tests/View Models/AddIdentityViewModelTests.swift index 8affc4f..8bcaccd 100644 --- a/Tests/View Models/AddIdentityViewModelTests.swift +++ b/Tests/View Models/AddIdentityViewModelTests.swift @@ -7,43 +7,45 @@ import CombineExpectations class AddIdentityViewModelTests: XCTestCase { func testAddIdentity() throws { - let environment = AppEnvironment.fresh() - let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment)) + let identityDatabase = IdentityDatabase.fresh() + let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase)) let addedIDRecorder = sut.addedIdentityID.record() sut.urlFieldText = "https://mastodon.social" - sut.goTapped() + sut.logInTapped() let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1) - let identityRecorder = environment.identityDatabase.identityObservation(id: addedIdentityID).record() + let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record() let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1) + XCTAssertEqual(addedIdentity.id, addedIdentityID) XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) } func testAddIdentityWithoutScheme() throws { - let environment = AppEnvironment.fresh() - let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment)) + let identityDatabase = IdentityDatabase.fresh() + let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase)) let addedIDRecorder = sut.addedIdentityID.record() sut.urlFieldText = "mastodon.social" - sut.goTapped() + sut.logInTapped() let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1) - let identityRecorder = environment.identityDatabase.identityObservation(id: addedIdentityID).record() + let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record() let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1) + XCTAssertEqual(addedIdentity.id, addedIdentityID) XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) } func testInvalidURL() throws { - let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: .fresh())) + let sut = AddIdentityViewModel(identitiesService: .fresh()) let recorder = sut.$alertItem.record() XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) sut.urlFieldText = "🐘.social" - sut.goTapped() + sut.logInTapped() let alertItem = try wait(for: recorder.next(), timeout: 1) @@ -51,14 +53,20 @@ class AddIdentityViewModelTests: XCTestCase { } func testDoesNotAlertCanceledLogin() throws { - let environment = AppEnvironment.fresh(webAuthSessionType: CanceledLoginMockWebAuthSession.self) - let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment)) + let environment = AppEnvironment( + URLSessionConfiguration: .stubbing, + webAuthSessionType: CanceledLoginMockWebAuthSession.self) + let identitiesService = IdentitiesService( + identityDatabase: .fresh(), + keychainService: MockKeychainService(), + environment: environment) + let sut = AddIdentityViewModel(identitiesService: identitiesService) let recorder = sut.$alertItem.record() XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) sut.urlFieldText = "https://mastodon.social" - sut.goTapped() + sut.logInTapped() try wait(for: recorder.next().inverted, timeout: 1) } diff --git a/Tests/View Models/RootViewModelTests.swift b/Tests/View Models/RootViewModelTests.swift index a058e95..d21252e 100644 --- a/Tests/View Models/RootViewModelTests.swift +++ b/Tests/View Models/RootViewModelTests.swift @@ -9,9 +9,10 @@ class RootViewModelTests: XCTestCase { var cancellables = Set() func testAddIdentity() throws { - let environment = AppEnvironment.fresh() - let sut = RootViewModel( - identitiesService: IdentitiesService(environment: environment)) + let sut = RootViewModel(identitiesService: IdentitiesService( + identityDatabase: .fresh(), + keychainService: MockKeychainService(), + environment: .development)) let recorder = sut.$mainNavigationViewModel.record() XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) @@ -23,7 +24,7 @@ class RootViewModelTests: XCTestCase { .store(in: &cancellables) addIdentityViewModel.urlFieldText = "https://mastodon.social" - addIdentityViewModel.goTapped() + addIdentityViewModel.logInTapped() let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)!