mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 09:41:00 +00:00
Add anonymous instance, lots of refactoring
This commit is contained in:
parent
3995726a45
commit
96fdbad7b9
16 changed files with 235 additions and 296 deletions
|
@ -23,18 +23,6 @@ let developmentKeychainService: KeychainServiceType = {
|
||||||
return keychainService
|
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 {
|
extension Account {
|
||||||
static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8))
|
static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8))
|
||||||
}
|
}
|
||||||
|
@ -69,30 +57,26 @@ extension IdentityDatabase {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AppEnvironment {
|
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(
|
static let development = AppEnvironment(
|
||||||
URLSessionConfiguration: .stubbing,
|
URLSessionConfiguration: .stubbing,
|
||||||
identityDatabase: .development,
|
|
||||||
defaults: .development,
|
|
||||||
keychainService: developmentKeychainService,
|
|
||||||
webAuthSessionType: SuccessfulMockWebAuthSession.self)
|
webAuthSessionType: SuccessfulMockWebAuthSession.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension IdentitiesService {
|
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 {
|
extension IdentityService {
|
||||||
|
|
|
@ -57,8 +57,6 @@
|
||||||
D052BBC724D749C800A80A7A /* RootViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC624D749C800A80A7A /* RootViewModelTests.swift */; };
|
D052BBC724D749C800A80A7A /* RootViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC624D749C800A80A7A /* RootViewModelTests.swift */; };
|
||||||
D052BBCA24D74C9200A80A7A /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */; };
|
D052BBCA24D74C9200A80A7A /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */; };
|
||||||
D052BBCB24D74C9300A80A7A /* 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 */; };
|
D052BBD124D750CA00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; };
|
||||||
D052BBD224D750CB00A80A7A /* 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 */; };
|
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 = "<group>"; };
|
D052BBC624D749C800A80A7A /* RootViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = "<group>"; };
|
D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = "<group>"; };
|
||||||
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = "<group>"; };
|
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = "<group>"; };
|
||||||
D052BBCE24D750C000A80A7A /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = "<group>"; };
|
|
||||||
D065F53A24D3B33A00741304 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
|
D065F53A24D3B33A00741304 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
@ -446,7 +443,6 @@
|
||||||
D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */,
|
D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */,
|
||||||
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */,
|
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */,
|
||||||
D0ED1BD624CF94B200B4899C /* Application.swift */,
|
D0ED1BD624CF94B200B4899C /* Application.swift */,
|
||||||
D052BBCE24D750C000A80A7A /* Defaults.swift */,
|
|
||||||
D0666A5324C6C3E500F3F04B /* Emoji.swift */,
|
D0666A5324C6C3E500F3F04B /* Emoji.swift */,
|
||||||
D0666A4A24C6C37700F3F04B /* Identity.swift */,
|
D0666A4A24C6C37700F3F04B /* Identity.swift */,
|
||||||
D0666A4D24C6C39600F3F04B /* Instance.swift */,
|
D0666A4D24C6C39600F3F04B /* Instance.swift */,
|
||||||
|
@ -778,7 +774,6 @@
|
||||||
D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */,
|
D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */,
|
||||||
D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */,
|
D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */,
|
||||||
D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
|
D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
|
||||||
D052BBCF24D750C000A80A7A /* Defaults.swift in Sources */,
|
|
||||||
D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */,
|
D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */,
|
||||||
D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
|
D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
|
||||||
D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */,
|
D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */,
|
||||||
|
@ -859,7 +854,6 @@
|
||||||
D0159FA624DE98F600E78478 /* NSMutableAttributedString+Extensions.swift in Sources */,
|
D0159FA624DE98F600E78478 /* NSMutableAttributedString+Extensions.swift in Sources */,
|
||||||
D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */,
|
D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */,
|
||||||
D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */,
|
D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */,
|
||||||
D052BBD024D750C000A80A7A /* Defaults.swift in Sources */,
|
|
||||||
D0ED1BE424CFA84400B4899C /* MastodonError.swift in Sources */,
|
D0ED1BE424CFA84400B4899C /* MastodonError.swift in Sources */,
|
||||||
D0666A6424C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
|
D0666A6424C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
|
||||||
D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */,
|
D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */,
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
// Copyright © 2020 Metabolist. All rights reserved.
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
"go" = "Go";
|
|
||||||
"add-identity.instance-url" = "Instance URL";
|
"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";
|
"oauth.error.code-not-found" = "OAuth error: code not found";
|
||||||
"secondary-navigation.manage-accounts" = "Manage Accounts";
|
"secondary-navigation.manage-accounts" = "Manage Accounts";
|
||||||
"secondary-navigation.preferences" = "Preferences";
|
"secondary-navigation.preferences" = "Preferences";
|
||||||
|
|
|
@ -4,28 +4,27 @@ import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct MetatextApp: App {
|
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() {
|
init() {
|
||||||
let identityDatabase: IdentityDatabase
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try identityDatabase = IdentityDatabase()
|
try identityDatabase = IdentityDatabase()
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Failed to initialize identity database")
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView(viewModel: RootViewModel(identitiesService: IdentitiesService(environment: environment)))
|
RootView(
|
||||||
|
viewModel: RootViewModel(identitiesService: IdentitiesService(
|
||||||
|
identityDatabase: identityDatabase,
|
||||||
|
keychainService: keychainServive,
|
||||||
|
environment: environment)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,5 @@ import Foundation
|
||||||
|
|
||||||
struct AppEnvironment {
|
struct AppEnvironment {
|
||||||
let URLSessionConfiguration: URLSessionConfiguration
|
let URLSessionConfiguration: URLSessionConfiguration
|
||||||
let identityDatabase: IdentityDatabase
|
|
||||||
let defaults: Defaults
|
|
||||||
let keychainService: KeychainServiceType
|
|
||||||
let webAuthSessionType: WebAuthSessionType.Type
|
let webAuthSessionType: WebAuthSessionType.Type
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<T>(index: Defaults.Item) -> T? {
|
|
||||||
get { userDefaults.value(forKey: index.rawValue) as? T }
|
|
||||||
set { userDefaults.set(newValue, forKey: index.rawValue) }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,25 +4,4 @@ import Foundation
|
||||||
|
|
||||||
struct MastodonAPI {
|
struct MastodonAPI {
|
||||||
static let dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
|
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: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,168 +4,109 @@ import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
struct AuthenticationService {
|
struct AuthenticationService {
|
||||||
private let environment: AppEnvironment
|
|
||||||
private let networkClient: MastodonClient
|
private let networkClient: MastodonClient
|
||||||
|
private let webAuthSessionType: WebAuthSessionType.Type
|
||||||
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
|
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
|
||||||
|
|
||||||
init(environment: AppEnvironment) {
|
init(environment: AppEnvironment) {
|
||||||
self.environment = environment
|
|
||||||
networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
|
networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
|
||||||
|
webAuthSessionType = environment.webAuthSessionType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AuthenticationService {
|
extension AuthenticationService {
|
||||||
func authenticate(instanceURL: URL) -> AnyPublisher<UUID, Error> {
|
func authorizeApp(instanceURL: URL) -> AnyPublisher<AppAuthorization, Error> {
|
||||||
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<AppAuthorization, Error> {
|
|
||||||
let endpoint = AppAuthorizationEndpoint.apps(
|
let endpoint = AppAuthorizationEndpoint.apps(
|
||||||
clientName: MastodonAPI.OAuth.clientName,
|
clientName: OAuth.clientName,
|
||||||
redirectURI: redirectURL.absoluteString,
|
redirectURI: OAuth.callbackURL.absoluteString,
|
||||||
scopes: MastodonAPI.OAuth.scopes,
|
scopes: OAuth.scopes,
|
||||||
website: nil)
|
website: OAuth.website)
|
||||||
let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil)
|
let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil)
|
||||||
|
|
||||||
return networkClient.request(target)
|
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<AccessToken, Error> {
|
||||||
|
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<URL?, Error> 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<AccessToken, Error> 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()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Publisher where Output == AppAuthorization {
|
private extension AuthenticationService {
|
||||||
func authenticationURL(instanceURL: URL, redirectURL: URL) -> AnyPublisher<(AppAuthorization, URL), Error> {
|
struct OAuth {
|
||||||
tryMap { appAuthorization in
|
static let clientName = "Metatext"
|
||||||
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
|
static let scopes = "read write follow push"
|
||||||
throw URLError(.badURL)
|
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"
|
enum OAuthError {
|
||||||
authorizationURLComponents.queryItems = [
|
case codeNotFound
|
||||||
"client_id": appAuthorization.clientId,
|
}
|
||||||
"scope": MastodonAPI.OAuth.scopes,
|
|
||||||
"response_type": "code",
|
|
||||||
"redirect_uri": redirectURL.absoluteString
|
|
||||||
].map { URLQueryItem(name: $0, value: $1) }
|
|
||||||
|
|
||||||
guard let authorizationURL = authorizationURLComponents.url else {
|
private func authorizationURL(instanceURL: URL, clientID: String) -> URL? {
|
||||||
throw URLError(.badURL)
|
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
|
||||||
}
|
return nil
|
||||||
|
|
||||||
return (appAuthorization, authorizationURL)
|
|
||||||
}
|
}
|
||||||
.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 {
|
extension AuthenticationService.OAuthError: LocalizedError {
|
||||||
func authenticate(
|
var errorDescription: String? {
|
||||||
webAuthSessionType: WebAuthSessionType.Type,
|
switch self {
|
||||||
contextProvider: WebAuthSessionContextProvider,
|
case .codeNotFound:
|
||||||
callbackURLScheme: String) -> AnyPublisher<(AppAuthorization, URL), Error> {
|
return NSLocalizedString("oauth.error.code-not-found", comment: "")
|
||||||
flatMap { appAuthorization, url in
|
|
||||||
webAuthSessionType.publisher(
|
|
||||||
url: url,
|
|
||||||
callbackURLScheme: callbackURLScheme,
|
|
||||||
presentationContextProvider: contextProvider)
|
|
||||||
.tryCatch { error -> AnyPublisher<URL?, Error> in
|
|
||||||
if (error as? WebAuthSessionError)?.code == .canceledLogin {
|
|
||||||
return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
.compactMap { $0 }
|
|
||||||
.map { (appAuthorization, $0) }
|
|
||||||
}
|
}
|
||||||
.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<AccessToken, Error> {
|
|
||||||
flatMap { appAuthorization, code -> AnyPublisher<AccessToken, Error> 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<UUID, Error> {
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,12 +6,16 @@ import Combine
|
||||||
class IdentitiesService {
|
class IdentitiesService {
|
||||||
@Published var mostRecentlyUsedIdentityID: UUID?
|
@Published var mostRecentlyUsedIdentityID: UUID?
|
||||||
|
|
||||||
|
private let identityDatabase: IdentityDatabase
|
||||||
|
private let keychainService: KeychainServiceType
|
||||||
private let environment: AppEnvironment
|
private let environment: AppEnvironment
|
||||||
|
|
||||||
init(environment: AppEnvironment) {
|
init(identityDatabase: IdentityDatabase, keychainService: KeychainServiceType, environment: AppEnvironment) {
|
||||||
|
self.identityDatabase = identityDatabase
|
||||||
|
self.keychainService = keychainService
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
|
|
||||||
environment.identityDatabase.mostRecentlyUsedIdentityIDObservation()
|
identityDatabase.mostRecentlyUsedIdentityIDObservation()
|
||||||
.replaceError(with: nil)
|
.replaceError(with: nil)
|
||||||
.assign(to: &$mostRecentlyUsedIdentityID)
|
.assign(to: &$mostRecentlyUsedIdentityID)
|
||||||
}
|
}
|
||||||
|
@ -19,20 +23,43 @@ class IdentitiesService {
|
||||||
|
|
||||||
extension IdentitiesService {
|
extension IdentitiesService {
|
||||||
func identityService(id: UUID) throws -> IdentityService {
|
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 {
|
func createIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Void, Error> {
|
||||||
AuthenticationService(environment: environment)
|
identityDatabase.createIdentity(id: id, url: instanceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizeIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Void, Error> {
|
||||||
|
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<Void, Error> {
|
func deleteIdentity(id: UUID) -> AnyPublisher<Void, Error> {
|
||||||
environment.identityDatabase.deleteIdentity(id: id)
|
identityDatabase.deleteIdentity(id: id)
|
||||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||||
.tryMap { _, welf -> Void in
|
.tryMap { _, welf -> Void in
|
||||||
try SecretsService(
|
try SecretsService(
|
||||||
identityID: id,
|
identityID: id,
|
||||||
keychainService: welf.environment.keychainService)
|
keychainService: welf.keychainService)
|
||||||
.deleteAllItems()
|
.deleteAllItems()
|
||||||
|
|
||||||
return ()
|
return ()
|
||||||
|
|
|
@ -7,15 +7,20 @@ class IdentityService {
|
||||||
@Published private(set) var identity: Identity
|
@Published private(set) var identity: Identity
|
||||||
let observationErrors: AnyPublisher<Error, Never>
|
let observationErrors: AnyPublisher<Error, Never>
|
||||||
|
|
||||||
private let networkClient: MastodonClient
|
private let identityDatabase: IdentityDatabase
|
||||||
private let environment: AppEnvironment
|
private let environment: AppEnvironment
|
||||||
|
private let networkClient: MastodonClient
|
||||||
private let observationErrorsInput = PassthroughSubject<Error, Never>()
|
private let observationErrorsInput = PassthroughSubject<Error, Never>()
|
||||||
|
|
||||||
init(identityID: UUID, environment: AppEnvironment) throws {
|
init(identityID: UUID,
|
||||||
|
identityDatabase: IdentityDatabase,
|
||||||
|
keychainService: KeychainServiceType,
|
||||||
|
environment: AppEnvironment) throws {
|
||||||
|
self.identityDatabase = identityDatabase
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
observationErrors = observationErrorsInput.eraseToAnyPublisher()
|
observationErrors = observationErrorsInput.eraseToAnyPublisher()
|
||||||
|
|
||||||
let observation = environment.identityDatabase.identityObservation(id: identityID).share()
|
let observation = identityDatabase.identityObservation(id: identityID).share()
|
||||||
var initialIdentity: Identity?
|
var initialIdentity: Identity?
|
||||||
|
|
||||||
_ = observation.first().sink(
|
_ = observation.first().sink(
|
||||||
|
@ -29,7 +34,7 @@ class IdentityService {
|
||||||
networkClient.instanceURL = identity.url
|
networkClient.instanceURL = identity.url
|
||||||
networkClient.accessToken = try SecretsService(
|
networkClient.accessToken = try SecretsService(
|
||||||
identityID: identityID,
|
identityID: identityID,
|
||||||
keychainService: environment.keychainService)
|
keychainService: keychainService)
|
||||||
.item(.accessToken)
|
.item(.accessToken)
|
||||||
|
|
||||||
observation.catch { [weak self] error -> Empty<Identity, Never> in
|
observation.catch { [weak self] error -> Empty<Identity, Never> in
|
||||||
|
@ -45,14 +50,14 @@ extension IdentityService {
|
||||||
var isAuthorized: Bool { networkClient.accessToken != nil }
|
var isAuthorized: Bool { networkClient.accessToken != nil }
|
||||||
|
|
||||||
func updateLastUse() -> AnyPublisher<Void, Error> {
|
func updateLastUse() -> AnyPublisher<Void, Error> {
|
||||||
environment.identityDatabase.updateLastUsedAt(identityID: identity.id)
|
identityDatabase.updateLastUsedAt(identityID: identity.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyCredentials() -> AnyPublisher<Void, Error> {
|
func verifyCredentials() -> AnyPublisher<Void, Error> {
|
||||||
networkClient.request(AccountEndpoint.verifyCredentials)
|
networkClient.request(AccountEndpoint.verifyCredentials)
|
||||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||||
.map { ($0, $1.identity.id) }
|
.map { ($0, $1.identity.id) }
|
||||||
.flatMap(environment.identityDatabase.updateAccount)
|
.flatMap(identityDatabase.updateAccount)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +65,7 @@ extension IdentityService {
|
||||||
networkClient.request(PreferencesEndpoint.preferences)
|
networkClient.request(PreferencesEndpoint.preferences)
|
||||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||||
.map { ($1.identity.preferences.updated(from: $0), $1.identity.id) }
|
.map { ($1.identity.preferences.updated(from: $0), $1.identity.id) }
|
||||||
.flatMap(environment.identityDatabase.updatePreferences)
|
.flatMap(identityDatabase.updatePreferences)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,19 +73,19 @@ extension IdentityService {
|
||||||
networkClient.request(InstanceEndpoint.instance)
|
networkClient.request(InstanceEndpoint.instance)
|
||||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||||
.map { ($0, $1.identity.id) }
|
.map { ($0, $1.identity.id) }
|
||||||
.flatMap(environment.identityDatabase.updateInstance)
|
.flatMap(identityDatabase.updateInstance)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func identitiesObservation() -> AnyPublisher<[Identity], Error> {
|
func identitiesObservation() -> AnyPublisher<[Identity], Error> {
|
||||||
environment.identityDatabase.identitiesObservation()
|
identityDatabase.identitiesObservation()
|
||||||
}
|
}
|
||||||
|
|
||||||
func recentIdentitiesObservation() -> AnyPublisher<[Identity], Error> {
|
func recentIdentitiesObservation() -> AnyPublisher<[Identity], Error> {
|
||||||
environment.identityDatabase.recentIdentitiesObservation(excluding: identity.id)
|
identityDatabase.recentIdentitiesObservation(excluding: identity.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher<Void, Error> {
|
func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher<Void, Error> {
|
||||||
environment.identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
|
identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,19 +9,31 @@ class AddIdentityViewModel: ObservableObject {
|
||||||
@Published private(set) var loading = false
|
@Published private(set) var loading = false
|
||||||
let addedIdentityID: AnyPublisher<UUID, Never>
|
let addedIdentityID: AnyPublisher<UUID, Never>
|
||||||
|
|
||||||
private let authenticationService: AuthenticationService
|
private let identitiesService: IdentitiesService
|
||||||
private let addedIdentityIDInput = PassthroughSubject<UUID, Never>()
|
private let addedIdentityIDInput = PassthroughSubject<UUID, Never>()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(authenticationService: AuthenticationService) {
|
init(identitiesService: IdentitiesService) {
|
||||||
self.authenticationService = authenticationService
|
self.identitiesService = identitiesService
|
||||||
addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher()
|
addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func goTapped() {
|
func logInTapped() {
|
||||||
Just(urlFieldText)
|
let identityID = UUID()
|
||||||
.tryMap { try $0.url() }
|
let instanceURL: URL
|
||||||
.flatMap(authenticationService.authenticate(instanceURL:))
|
|
||||||
|
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)
|
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||||
.receive(on: RunLoop.main)
|
.receive(on: RunLoop.main)
|
||||||
.handleEvents(
|
.handleEvents(
|
||||||
|
@ -30,4 +42,23 @@ class AddIdentityViewModel: ObservableObject {
|
||||||
.sink(receiveValue: addedIdentityIDInput.send)
|
.sink(receiveValue: addedIdentityIDInput.send)
|
||||||
.store(in: &cancellables)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,6 +52,6 @@ extension RootViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
func addIdentityViewModel() -> AddIdentityViewModel {
|
func addIdentityViewModel() -> AddIdentityViewModel {
|
||||||
AddIdentityViewModel(authenticationService: identitiesService.authenticationService())
|
AddIdentityViewModel(identitiesService: identitiesService)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,12 +21,13 @@ struct AddIdentityView: View {
|
||||||
if viewModel.loading {
|
if viewModel.loading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else {
|
} else {
|
||||||
Button(
|
Button("add-identity.log-in",
|
||||||
action: viewModel.goTapped,
|
action: viewModel.logInTapped)
|
||||||
label: { Text("go") })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Spacer()
|
Spacer()
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -6,26 +6,21 @@ import CombineExpectations
|
||||||
@testable import Metatext
|
@testable import Metatext
|
||||||
|
|
||||||
class AuthenticationServiceTests: XCTestCase {
|
class AuthenticationServiceTests: XCTestCase {
|
||||||
func testAddIdentity() throws {
|
func testAuthentication() throws {
|
||||||
let environment = AppEnvironment.fresh()
|
let sut = AuthenticationService(environment: .development)
|
||||||
let sut = AuthenticationService(environment: environment)
|
|
||||||
let instanceURL = URL(string: "https://mastodon.social")!
|
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)
|
XCTAssertEqual(appAuthorization.clientId, "AUTHORIZATION_CLIENT_ID_STUB_VALUE")
|
||||||
let identityRecorder = environment.identityDatabase.identityObservation(id: addedIdentityID).record()
|
XCTAssertEqual(appAuthorization.clientSecret, "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE")
|
||||||
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
|
|
||||||
|
|
||||||
XCTAssertEqual(addedIdentity.id, addedIdentityID)
|
let accessTokenRecorder = sut.authenticate(
|
||||||
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
|
instanceURL: instanceURL,
|
||||||
|
appAuthorization: appAuthorization)
|
||||||
|
.record()
|
||||||
|
let accessToken = try wait(for: accessTokenRecorder.next(), timeout: 1)
|
||||||
|
|
||||||
let secretsService = SecretsService(identityID: addedIdentity.id, keychainService: environment.keychainService)
|
XCTAssertEqual(accessToken.accessToken, "ACCESS_TOKEN_STUB_VALUE")
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,43 +7,45 @@ import CombineExpectations
|
||||||
|
|
||||||
class AddIdentityViewModelTests: XCTestCase {
|
class AddIdentityViewModelTests: XCTestCase {
|
||||||
func testAddIdentity() throws {
|
func testAddIdentity() throws {
|
||||||
let environment = AppEnvironment.fresh()
|
let identityDatabase = IdentityDatabase.fresh()
|
||||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
|
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
|
||||||
let addedIDRecorder = sut.addedIdentityID.record()
|
let addedIDRecorder = sut.addedIdentityID.record()
|
||||||
|
|
||||||
sut.urlFieldText = "https://mastodon.social"
|
sut.urlFieldText = "https://mastodon.social"
|
||||||
sut.goTapped()
|
sut.logInTapped()
|
||||||
|
|
||||||
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1)
|
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)
|
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
|
||||||
|
|
||||||
|
XCTAssertEqual(addedIdentity.id, addedIdentityID)
|
||||||
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
|
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAddIdentityWithoutScheme() throws {
|
func testAddIdentityWithoutScheme() throws {
|
||||||
let environment = AppEnvironment.fresh()
|
let identityDatabase = IdentityDatabase.fresh()
|
||||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
|
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
|
||||||
let addedIDRecorder = sut.addedIdentityID.record()
|
let addedIDRecorder = sut.addedIdentityID.record()
|
||||||
|
|
||||||
sut.urlFieldText = "mastodon.social"
|
sut.urlFieldText = "mastodon.social"
|
||||||
sut.goTapped()
|
sut.logInTapped()
|
||||||
|
|
||||||
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1)
|
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)
|
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
|
||||||
|
|
||||||
|
XCTAssertEqual(addedIdentity.id, addedIdentityID)
|
||||||
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
|
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInvalidURL() throws {
|
func testInvalidURL() throws {
|
||||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: .fresh()))
|
let sut = AddIdentityViewModel(identitiesService: .fresh())
|
||||||
let recorder = sut.$alertItem.record()
|
let recorder = sut.$alertItem.record()
|
||||||
|
|
||||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||||
|
|
||||||
sut.urlFieldText = "🐘.social"
|
sut.urlFieldText = "🐘.social"
|
||||||
sut.goTapped()
|
sut.logInTapped()
|
||||||
|
|
||||||
let alertItem = try wait(for: recorder.next(), timeout: 1)
|
let alertItem = try wait(for: recorder.next(), timeout: 1)
|
||||||
|
|
||||||
|
@ -51,14 +53,20 @@ class AddIdentityViewModelTests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDoesNotAlertCanceledLogin() throws {
|
func testDoesNotAlertCanceledLogin() throws {
|
||||||
let environment = AppEnvironment.fresh(webAuthSessionType: CanceledLoginMockWebAuthSession.self)
|
let environment = AppEnvironment(
|
||||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
|
URLSessionConfiguration: .stubbing,
|
||||||
|
webAuthSessionType: CanceledLoginMockWebAuthSession.self)
|
||||||
|
let identitiesService = IdentitiesService(
|
||||||
|
identityDatabase: .fresh(),
|
||||||
|
keychainService: MockKeychainService(),
|
||||||
|
environment: environment)
|
||||||
|
let sut = AddIdentityViewModel(identitiesService: identitiesService)
|
||||||
let recorder = sut.$alertItem.record()
|
let recorder = sut.$alertItem.record()
|
||||||
|
|
||||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||||
|
|
||||||
sut.urlFieldText = "https://mastodon.social"
|
sut.urlFieldText = "https://mastodon.social"
|
||||||
sut.goTapped()
|
sut.logInTapped()
|
||||||
|
|
||||||
try wait(for: recorder.next().inverted, timeout: 1)
|
try wait(for: recorder.next().inverted, timeout: 1)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,9 +9,10 @@ class RootViewModelTests: XCTestCase {
|
||||||
var cancellables = Set<AnyCancellable>()
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
func testAddIdentity() throws {
|
func testAddIdentity() throws {
|
||||||
let environment = AppEnvironment.fresh()
|
let sut = RootViewModel(identitiesService: IdentitiesService(
|
||||||
let sut = RootViewModel(
|
identityDatabase: .fresh(),
|
||||||
identitiesService: IdentitiesService(environment: environment))
|
keychainService: MockKeychainService(),
|
||||||
|
environment: .development))
|
||||||
let recorder = sut.$mainNavigationViewModel.record()
|
let recorder = sut.$mainNavigationViewModel.record()
|
||||||
|
|
||||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||||
|
@ -23,7 +24,7 @@ class RootViewModelTests: XCTestCase {
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
addIdentityViewModel.urlFieldText = "https://mastodon.social"
|
addIdentityViewModel.urlFieldText = "https://mastodon.social"
|
||||||
addIdentityViewModel.goTapped()
|
addIdentityViewModel.logInTapped()
|
||||||
|
|
||||||
let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)!
|
let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)!
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue