From 0cf27e05618594c527edfe4d8f4b5c0e31b53976 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Tue, 11 Jul 2023 15:05:32 +0200 Subject: [PATCH 01/13] Migrate to SpeziAccount refactoring * Added a bunch more error messages --- Package.swift | 2 +- .../FirebaseAccountConfiguration.swift | 69 ++------ .../FirebaseAccountError.swift | 20 ++- .../FirebaseEmailPasswordAccountService.swift | 158 ++++++++++++------ .../Resources/de.lproj/Localizable.strings | 13 +- .../Resources/en.lproj/Localizable.strings | 13 +- .../UITests/UITests.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/swiftpm/Package.resolved | 36 +++- 8 files changed, 183 insertions(+), 134 deletions(-) diff --git a/Package.swift b/Package.swift index ea2efdd..fc888e6 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.7.0")), - .package(url: "https://github.com/StanfordSpezi/SpeziAccount", .upToNextMinor(from: "0.4.0")), + .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/simple-account-view"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") ], targets: [ diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift index 55be376..f5151e5 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift @@ -34,24 +34,13 @@ import SpeziFirebaseConfiguration /// } /// } /// ``` -public final class FirebaseAccountConfiguration: Component, ObservableObject, ObservableObjectProvider { +public final class FirebaseAccountConfiguration: Component { @Dependency private var configureFirebaseApp: ConfigureFirebaseApp - + private let emulatorSettings: (host: String, port: Int)? private let authenticationMethods: FirebaseAuthAuthenticationMethods - private let account: Account - private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? - - @MainActor @Published public var user: User? - - - public var observableObjects: [any ObservableObject] { - [ - self, - account - ] - } - + + public let accountService: FirebaseEmailPasswordAccountService // TODO this protocol requirement requires us to make the service public! /// - Parameters: /// - emulatorSettings: The emulator settings. The default value is `nil`, connecting the FirebaseAccount module to the Firebase Auth cloud instance. @@ -62,55 +51,17 @@ public final class FirebaseAccountConfiguration: Component, ObservableObject, Ob ) { self.emulatorSettings = emulatorSettings self.authenticationMethods = authenticationMethods - - - var accountServices: [any AccountService] = [] - if authenticationMethods.contains(.emailAndPassword) { - accountServices.append(FirebaseEmailPasswordAccountService()) - } - self.account = Account(accountServices: accountServices) + + // TODO at least one authenticationMethod! + // if authenticationMethods.contains(.emailAndPassword) + self.accountService = FirebaseEmailPasswordAccountService() } - public func configure() { if let emulatorSettings { Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) } - - authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener { _, user in - guard let user else { - self.updateSignedOut() - return - } - - self.updateSignedIn(user) - } - - Auth.auth().currentUser?.getIDTokenForcingRefresh(true) { _, error in - guard error == nil else { - self.updateSignedOut() - return - } - } - } - - private func updateSignedOut() { - Task { - await MainActor.run { - self.user = nil - self.account.signedIn = false - } - } - } - - private func updateSignedIn(_ user: User) { - Task { - await MainActor.run { - self.user = user - if self.account.signedIn == false { - self.account.signedIn = true - } - } - } + + accountService.configure() } } diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift index 628c919..eccb670 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift @@ -14,6 +14,8 @@ enum FirebaseAccountError: LocalizedError { case invalidEmail case accountAlreadyInUse case weakPassword + case invalidCredentials + case internalPasswordResetError case setupError case unknown(AuthErrorCode.Code) @@ -26,6 +28,10 @@ enum FirebaseAccountError: LocalizedError { return "FIREBASE_ACCOUNT_ALREADY_IN_USE" case .weakPassword: return "FIREBASE_ACCOUNT_WEAK_PASSWORD" + case .invalidCredentials: + return "FIREBASE_ACCOUNT_INVALID_CREDENTIALS" + case .internalPasswordResetError: + return "FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET" case .setupError: return "FIREBASE_ACCOUNT_SETUP_ERROR" case .unknown: @@ -37,10 +43,6 @@ enum FirebaseAccountError: LocalizedError { .init(localized: errorDescriptionValue, bundle: .module) } - var failureReason: String? { - errorDescription - } - private var recoverySuggestionValue: String.LocalizationValue { switch self { case .invalidEmail: @@ -49,6 +51,10 @@ enum FirebaseAccountError: LocalizedError { return "FIREBASE_ACCOUNT_ALREADY_IN_USE_SUGGESTION" case .weakPassword: return "FIREBASE_ACCOUNT_WEAK_PASSWORD_SUGGESTION" + case .invalidCredentials: + return "FIREBASE_ACCOUNT_INVALID_CREDENTIALS_SUGGESTION" + case .internalPasswordResetError: + return "FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET_SUGGESTION" case .setupError: return "FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" case .unknown: @@ -63,12 +69,16 @@ enum FirebaseAccountError: LocalizedError { init(authErrorCode: AuthErrorCode) { switch authErrorCode.code { - case .invalidEmail: + case .invalidEmail, .invalidRecipientEmail: self = .invalidEmail case .emailAlreadyInUse: self = .accountAlreadyInUse case .weakPassword: self = .weakPassword + case .userDisabled, .wrongPassword: + self = .invalidCredentials + case .invalidSender, .invalidMessagePayload: + self = .internalPasswordResetError case .operationNotAllowed, .invalidAPIKey, .appNotAuthorized, .keychainError, .internalError: self = .setupError default: diff --git a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift index 7b27f52..ab9c093 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift @@ -11,7 +11,7 @@ import SpeziAccount import SwiftUI -class FirebaseEmailPasswordAccountService: EmailPasswordAccountService { +public class FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { static var defaultPasswordValidationRule: ValidationRule { guard let regex = try? Regex(#"[^\s]{8,}"#) else { fatalError("Invalid Password Regex in the FirebaseEmailPasswordAccountService") @@ -19,83 +19,139 @@ class FirebaseEmailPasswordAccountService: EmailPasswordAccountService { return ValidationRule( regex: regex, - message: String(localized: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR", bundle: .module) + message: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR", + bundle: .module ) } - - - private var passwordValidationRule: ValidationRule - - - override var loginButton: AnyView { - button( - localization.login.buttonTitle, - destination: UsernamePasswordLoginView( - usernameValidationRules: [emailValidationRule], - passwordValidationRules: [passwordValidationRule] - ) - ) - } - - override var signUpButton: AnyView { - button( - localization.signUp.buttonTitle, - destination: UsernamePasswordSignUpView( - signUpOptions: [.usernameAndPassword, .name], - usernameValidationRules: [emailValidationRule], - passwordValidationRules: [passwordValidationRule] - ) + + @WeakInjectable // TODO AccountReference is internal! + private var account: Account + + public let configuration: UserIdPasswordServiceConfiguration + private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? + + // TODO make this configurable? + init(passwordValidationRules: [ValidationRule] = [FirebaseEmailPasswordAccountService.defaultPasswordValidationRule]) { + self.configuration = .init( + name: LocalizedStringResource("FIREBASE_EMAIL_AND_PASSWORD", bundle: .atURL(from: .module)), + image: Image(systemName: "envelope.fill"), + signUpRequirements: AccountValueRequirements { + UserIdAccountValueKey.self + PasswordAccountValueKey.self + NameAccountValueKey.self + }, + userIdType: .emailAddress, + userIdField: .emailAddress, + userIdSignupValidations: [.minimalEmailValidationRule], + passwordSignupValidations: passwordValidationRules ) } - - - init(passwordValidationRule: ValidationRule = FirebaseEmailPasswordAccountService.defaultPasswordValidationRule) { - self.passwordValidationRule = passwordValidationRule - super.init() + + public func configure() { + authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener(stateDidChangeListener) + + // if there is a cached user, we refresh the authentication token + Auth.auth().currentUser?.getIDTokenForcingRefresh(true) { _, error in + if error != nil { + Task { + await self.notifyUserRemoval() + } + } + } } - - - override func login(username: String, password: String) async throws { + + public func login(userId: String, password: String) async throws { do { - try await Auth.auth().signIn(withEmail: username, password: password) - - Task { @MainActor in - account?.objectWillChange.send() - } + try await Auth.auth().signIn(withEmail: userId, password: password) + + // TODO why did we trigger? + // Task { @MainActor in + // account?.objectWillChange.send() + // } + } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { throw FirebaseAccountError.unknown(.internalError) } } - - - override func signUp(signUpValues: SignUpValues) async throws { + + public func signUp(signupRequest: SignupRequest) async throws { do { - let authResult = try await Auth.auth().createUser(withEmail: signUpValues.username, password: signUpValues.password) - + let authResult = try await Auth.auth().createUser(withEmail: signupRequest.userId, password: signupRequest.password) + let profileChangeRequest = authResult.user.createProfileChangeRequest() - profileChangeRequest.displayName = signUpValues.name.formatted(.name(style: .long)) + profileChangeRequest.displayName = signupRequest.name.formatted(.name(style: .long)) try await profileChangeRequest.commitChanges() - Task { @MainActor in - account?.objectWillChange.send() - } - + + // TODO why did we trigger? + // Task { @MainActor in + // account?.objectWillChange.send() + // } + try await authResult.user.sendEmailVerification() } catch let error as NSError { + // TODO can we inline the `invalidEmail` and `weakPassword` errors? => weakPassword has to be updated in the validation rule! throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { throw FirebaseAccountError.unknown(.internalError) } } - - override func resetPassword(username: String) async throws { + + public func resetPassword(userId: String) async throws { do { - try await Auth.auth().sendPasswordReset(withEmail: username) + try await Auth.auth().sendPasswordReset(withEmail: userId) } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { throw FirebaseAccountError.unknown(.internalError) } } + + public func logout() async throws { + do { + try Auth.auth().signOut() + + // TODO verify that this results in the user getting removed? + } catch let error as NSError { + throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + } catch { + throw FirebaseAccountError.unknown(.internalError) + } + } + + private func stateDidChangeListener(auth: Auth, user: User?) { + Task { + if let user { + await notifyUserSignIn(user: user) + } else { + await notifyUserRemoval() + } + } + } + + func notifyUserSignIn(user: User) async { + guard let email = user.email, + let displayName = user.displayName else { + // TODO log + return // TODO how to propagate back the error? + } + + guard let nameComponents = try? PersonNameComponents(displayName) else { + // we wouldn't be here if we couldn't create the person name components from the given string + // TODO log (still show error somehow?) + return + } + + let details = AccountDetails.Builder() + .add(UserIdAccountValueKey.self, value: email) + .add(NameAccountValueKey.self, value: nameComponents) + .build(owner: self) + + await account.supplyUserInfo(details) + } + + func notifyUserRemoval() async { + await account.removeUserInfo() + } } diff --git a/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings b/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings index f24e846..c5b80b2 100644 --- a/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings +++ b/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings @@ -6,12 +6,13 @@ // SPDX-License-Identifier: MIT // +"FIREBASE_EMAIL_AND_PASSWORD" = "E-Mail und Password"; // MARK: ERRORS "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR" = "Ein Password muss mindestens acht Zeichen und keine Leerzeichen haben."; "FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL" = "Inkorrekte E-Mail Adresse"; -"FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL_SUGGESTION" = "Bitte geben Sie eine korrekte E-Mail Adressse ein."; +"FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL_SUGGESTION" = "Bitte geben Sie eine korrekte E-Mail Adresse ein."; "FIREBASE_ACCOUNT_ALREADY_IN_USE" = "Benutzerkonto existiert bereits"; "FIREBASE_ACCOUNT_ALREADY_IN_USE_SUGGESTION" = "Ein Benutzerkonto mit dieser E-Mail Adresse existiert bereits. Bitte loggen Sie sich mit dem Account ein."; @@ -19,8 +20,14 @@ "FIREBASE_ACCOUNT_WEAK_PASSWORD" = "Schwaches Passwort"; "FIREBASE_ACCOUNT_WEAK_PASSWORD_SUGGESTION" = "Bitte geben Sie ein stärkeres Password ein."; +"FIREBASE_ACCOUNT_INVALID_CREDENTIALS" = "Ungültige Zugangsdaten"; +"FIREBASE_ACCOUNT_INVALID_CREDENTIALS_SUGGESTION" = "Bitte überprüfe die eingegebene E-Mail Adresse und dein Passwort."; + +"FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET" = "Zurücksetzten des Passworts fehlgeschlagen"; +"FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET_SUGGESTION" = "Es ist ein Fehler aufgetreten bei dem Versuch Ihr Passwort zurückzusetzen. Bitte versuchen Sie es erneut."; + "FIREBASE_ACCOUNT_SETUP_ERROR" = "Benutzerkonto konnte nicht erstelle werden"; -"FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" = "Bitte überprüfen Sie Ihre E-Mail Adresse und versichen Sie es erneut"; +"FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" = "Es ist ein Fehler aufgetreten beim erstellen deines Benutzerkontos. Bitte versuche Sie es später erneut."; "FIREBASE_ACCOUNT_UNKNOWN" = "Benutzerkonto konnte nicht erstelle werden"; -"FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" = "Bitte überprüfen Sie Ihre E-Mail Adresse und versichen Sie es erneut"; +"FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" = "Bitte überprüfen Sie Ihre E-Mail Adresse und versuchen Sie es erneut."; diff --git a/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings b/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings index 630fd9c..12880dc 100644 --- a/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings +++ b/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +"FIREBASE_EMAIL_AND_PASSWORD" = "E-Mail and Password"; // MARK: ERRORS "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR" = "A password has to have at least eight characters and no whitespaces."; @@ -19,8 +20,14 @@ "FIREBASE_ACCOUNT_WEAK_PASSWORD" = "Weak Password"; "FIREBASE_ACCOUNT_WEAK_PASSWORD_SUGGESTION" = "Please choose a safer password."; -"FIREBASE_ACCOUNT_SETUP_ERROR" = "Could not create the user account."; -"FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" = "Please check your internet connection and try again."; +"FIREBASE_ACCOUNT_INVALID_CREDENTIALS" = "Invalid Credentials"; +"FIREBASE_ACCOUNT_INVALID_CREDENTIALS_SUGGESTION" = "Please verify that your email and password are correct."; -"FIREBASE_ACCOUNT_UNKNOWN" = "Could not create the user account."; +"FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET" = "Failed to reset password"; +"FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET_SUGGESTION" = "There was an issue delivering the password reset email. Please try again."; + +"FIREBASE_ACCOUNT_SETUP_ERROR" = "Could not create the user account"; +"FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" = "There was an internal error when trying to create your user account. Please try again later."; + +"FIREBASE_ACCOUNT_UNKNOWN" = "Could not create the user account"; "FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" = "Please check your internet connection and try again."; diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 21333ef..9f7e007 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -565,8 +565,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordSpezi/SpeziAccount.git"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.4.0; + branch = "feature/simple-account-view"; + kind = branch; }; }; 2F2E8EFD29E7369B00D439B7 /* XCRemoteSwiftPackageReference "SpeziViews" */ = { @@ -574,7 +574,7 @@ repositoryURL = "https://github.com/StanfordSpezi/SpeziViews.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.3.0; + minimumVersion = 0.4.0; }; }; 2F746D9D29962B2A00BF54FE /* XCRemoteSwiftPackageReference "XCTestExtensions" */ = { diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 792ecc9..833de11 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "df2171b0c6afb9e9d4f7e07669d558c510b9f6be", - "version" : "10.13.0" + "revision" : "2bfe6abe1014aafe5cf28401708f7d39f9926a76", + "version" : "10.14.0" } }, { @@ -63,6 +63,15 @@ "version" : "3.1.1" } }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", + "version" : "100.0.0" + } + }, { "identity" : "leveldb", "kind" : "remoteSourceControl", @@ -95,17 +104,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/Spezi", "state" : { - "revision" : "c98b2e550d83050b76693765884405737c302a77", - "version" : "0.7.0" + "revision" : "7462510badaa156c1e25efd7eabbf5b85ecb0098", + "version" : "0.7.2" } }, { "identity" : "speziaccount", "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", + "location" : "https://github.com/StanfordSpezi/SpeziAccount", "state" : { - "revision" : "5828932267c9f371bd0d821d85f915f07481739a", - "version" : "0.4.0" + "branch" : "feature/simple-account-view", + "revision" : "9290d37ce2c631e241127662654a37b34de18281" } }, { @@ -113,8 +122,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziViews.git", "state" : { - "revision" : "c6975e84c735b8b8a13740012c1c194f48893fa8", - "version" : "0.3.0" + "revision" : "3131708f262064231751a8963eb263f8648b6879", + "version" : "0.4.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" } }, { From a95c02f2428b22d35e401fe8bbada22ea9c36563 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 31 Aug 2023 17:07:41 +0200 Subject: [PATCH 02/13] Some updates --- .../AccountValues/FirebaseEmailVerifiedKey.swift | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift diff --git a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift new file mode 100644 index 0000000..7b6f4b4 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift @@ -0,0 +1,5 @@ +// +// Created by Andreas Bauer on 14.07.23. +// + +import Foundation From a11104624b4dc453c0ffee65381440f743a82300 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 31 Aug 2023 17:08:04 +0200 Subject: [PATCH 03/13] Update firebase account --- .../FirebaseEmailVerifiedKey.swift | 20 ++++++- .../FirebaseAccountConfiguration.swift | 4 +- .../FirebaseEmailPasswordAccountService.swift | 6 ++- .../FirebaseAccountTestsView.swift | 54 +++++-------------- Tests/UITests/TestApp/TestApp.swift | 4 +- 5 files changed, 41 insertions(+), 47 deletions(-) diff --git a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift index 7b6f4b4..78c1a20 100644 --- a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift +++ b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift @@ -1,5 +1,21 @@ // -// Created by Andreas Bauer on 14.07.23. +// This source file is part of the Stanford Spezi open-source project // +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziAccount + +// TODO docs +public struct FirebaseEmailVerifiedKey: OptionalAccountValueKey { + public typealias Value = Bool +} -import Foundation +// this property is not supported in SignupRequests +extension AccountDetails { + public var isEmailVerified: Bool? { // swiftlint:disable:this discouraged_optional_boolean + storage[FirebaseEmailVerifiedKey.self] + } +} diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift index f5151e5..e904d99 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift @@ -62,6 +62,8 @@ public final class FirebaseAccountConfiguration: Component { Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) } - accountService.configure() + Task { + await accountService.configure() + } } } diff --git a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift index ab9c093..2c48e00 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift @@ -1,7 +1,7 @@ // // This source file is part of the Stanford Spezi open-source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // @@ -11,7 +11,8 @@ import SpeziAccount import SwiftUI -public class FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { +// TODO do we want this actor requirement? +public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { static var defaultPasswordValidationRule: ValidationRule { guard let regex = try? Regex(#"[^\s]{8,}"#) else { fatalError("Invalid Password Regex in the FirebaseEmailPasswordAccountService") @@ -146,6 +147,7 @@ public class FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { let details = AccountDetails.Builder() .add(UserIdAccountValueKey.self, value: email) .add(NameAccountValueKey.self, value: nameComponents) + .add(FirebaseEmailVerifiedKey.self, value: user.isEmailVerified) .build(owner: self) await account.supplyUserInfo(details) diff --git a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift index a706be7..a573e20 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -15,54 +15,26 @@ import SwiftUI struct FirebaseAccountTestsView: View { - @EnvironmentObject var firebaseAccount: FirebaseAccountConfiguration @EnvironmentObject var account: Account - @State var showLogin = false - @State var showSignUp = false - + + @State var viewState: ViewState = .idle var body: some View { List { - if account.signedIn { + if let details = account.details { HStack { - if let displayName = firebaseAccount.user?.displayName, - let name = try? PersonNameComponents(displayName) { - UserProfileView(name: name) - .frame(height: 30) - } - if let email = firebaseAccount.user?.email { - Text(email) - } + UserProfileView(name: details.name) + .frame(height: 30) + Text(details.userId) // TODO specific email key? } - } - Button("Login") { - showLogin.toggle() - } - .disabled(account.signedIn) - Button("Sign Up") { - showSignUp.toggle() - } - .disabled(account.signedIn) - Button("Logout", role: .destructive) { - try? Auth.auth().signOut() - } - .disabled(!account.signedIn) - } - .sheet(isPresented: $showLogin) { - NavigationStack { - Login() - } - } - .sheet(isPresented: $showSignUp) { - NavigationStack { - SignUp() - } - } - .onChange(of: account.signedIn) { signedIn in - if signedIn { - showLogin = false - showSignUp = false + + // TODO rename this thing and move to SpeziViews! + AsyncDataEntrySubmitButton("Logout", state: $viewState) { + try await details.accountService.logout() } + } else { + AccountSetup() // TODO external parameter name } + } } } diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 4655e5a..7259d50 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -34,7 +34,9 @@ struct UITestsApp: App { } - @UIApplicationDelegateAdaptor(TestAppDelegate.self) var appDelegate + @UIApplicationDelegateAdaptor(TestAppDelegate.self) + var appDelegate + @State private var path = NavigationPath() From 54e52f49ab0ee500c8f40955a990c199ae6c019a Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 31 Aug 2023 19:28:51 +0200 Subject: [PATCH 04/13] Update firebase implementation --- .../FirebaseEmailVerifiedKey.swift | 42 +++- .../FirebaseAccountConfiguration.swift | 16 +- .../FirebaseAccountError.swift | 12 + .../FirebaseEmailPasswordAccountService.swift | 217 +++++++++++++----- .../Resources/de.lproj/Localizable.strings | 10 +- .../Resources/en.lproj/Localizable.strings | 8 +- .../FirebaseAccountTestsView.swift | 45 +++- .../UITests/TestApp/GoogleService-Info.plist | 2 +- .../TestApp/Shared/TestAppDelegate.swift | 6 + .../xcshareddata/swiftpm/Package.resolved | 8 +- .../xcshareddata/xcschemes/TestApp.xcscheme | 14 +- 11 files changed, 290 insertions(+), 90 deletions(-) diff --git a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift index 78c1a20..1a63fa9 100644 --- a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift +++ b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift @@ -6,16 +6,46 @@ // SPDX-License-Identifier: MIT // +import Foundation import SpeziAccount +import SwiftUI -// TODO docs -public struct FirebaseEmailVerifiedKey: OptionalAccountValueKey { + +/// Flag indicating if the firebase account has a verified email address. +/// +/// - Important: This key is read-only and cannot be modified. +public struct FirebaseEmailVerifiedKey: AccountKey { public typealias Value = Bool + public static var name: LocalizedStringResource = "E-Mail Verified" + public static var category: AccountKeyCategory = .other + public static var initialValue: InitialValue = .default(false) +} + + +extension AccountKeys { + /// The email-verified ``FirebaseEmailVerifiedKey`` metatype. + public var isEmailVerified: FirebaseEmailVerifiedKey.Type { + FirebaseEmailVerifiedKey.self + } +} + + +extension AccountValues { + /// Access if the user's email of their firebase account is verified. + public var isEmailVerified: Bool { + storage[FirebaseEmailVerifiedKey.self] ?? false + } } -// this property is not supported in SignupRequests -extension AccountDetails { - public var isEmailVerified: Bool? { // swiftlint:disable:this discouraged_optional_boolean - storage[FirebaseEmailVerifiedKey.self] + +extension FirebaseEmailVerifiedKey { + public struct DataEntry: DataEntryView { + public typealias Key = FirebaseEmailVerifiedKey + + public var body: some View { + Text("The FirebaseEmailVerifiedKey cannot be set!") + } + + public init(_ value: Binding) {} } } diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift index e904d99..1b76d54 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift @@ -40,7 +40,7 @@ public final class FirebaseAccountConfiguration: Component { private let emulatorSettings: (host: String, port: Int)? private let authenticationMethods: FirebaseAuthAuthenticationMethods - public let accountService: FirebaseEmailPasswordAccountService // TODO this protocol requirement requires us to make the service public! + @Provide var accountServices: [any AccountService] /// - Parameters: /// - emulatorSettings: The emulator settings. The default value is `nil`, connecting the FirebaseAccount module to the Firebase Auth cloud instance. @@ -51,10 +51,11 @@ public final class FirebaseAccountConfiguration: Component { ) { self.emulatorSettings = emulatorSettings self.authenticationMethods = authenticationMethods + self.accountServices = [] - // TODO at least one authenticationMethod! - // if authenticationMethods.contains(.emailAndPassword) - self.accountService = FirebaseEmailPasswordAccountService() + if authenticationMethods.contains(.emailAndPassword) { + self.accountServices.append(FirebaseEmailPasswordAccountService()) + } } public func configure() { @@ -63,7 +64,12 @@ public final class FirebaseAccountConfiguration: Component { } Task { - await accountService.configure() + // We might be configured above the AccountConfiguration and therfore the `Account` object + // might not be injected yet. + try? await Task.sleep(for: .milliseconds(10)) + for accountService in accountServices { + await (accountService as? FirebaseEmailPasswordAccountService)?.configure() + } } } } diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift index eccb670..0ca8f62 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift @@ -17,6 +17,8 @@ enum FirebaseAccountError: LocalizedError { case invalidCredentials case internalPasswordResetError case setupError + case notSignedIn + case requireRecentLogin case unknown(AuthErrorCode.Code) @@ -34,6 +36,10 @@ enum FirebaseAccountError: LocalizedError { return "FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET" case .setupError: return "FIREBASE_ACCOUNT_SETUP_ERROR" + case .notSignedIn: + return "FIREBASE_ACCOUNT_SIGN_IN_ERROR" + case .requireRecentLogin: + return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR" case .unknown: return "FIREBASE_ACCOUNT_UNKNOWN" } @@ -57,6 +63,10 @@ enum FirebaseAccountError: LocalizedError { return "FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET_SUGGESTION" case .setupError: return "FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" + case .notSignedIn: + return "FIREBASE_ACCOUNT_SIGN_IN_ERROR_SUGGESTION" + case .requireRecentLogin: + return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR_SUGGESTION" case .unknown: return "FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" } @@ -81,6 +91,8 @@ enum FirebaseAccountError: LocalizedError { self = .internalPasswordResetError case .operationNotAllowed, .invalidAPIKey, .appNotAuthorized, .keychainError, .internalError: self = .setupError + case .requiresRecentLogin: + self = .requireRecentLogin default: self = .unknown(authErrorCode.code) } diff --git a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift index 2c48e00..37797f0 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift @@ -7,12 +7,26 @@ // import FirebaseAuth +import OSLog import SpeziAccount import SwiftUI -// TODO do we want this actor requirement? -public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { +enum StateChangeResult { + case user(_ user: User) + case removed +} + + +actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { + private static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") + + private static let supportedKeys = AccountKeyCollection { + \.userId + \.password + \.name + } + static var defaultPasswordValidationRule: ValidationRule { guard let regex = try? Regex(#"[^\s]{8,}"#) else { fatalError("Invalid Password Regex in the FirebaseEmailPasswordAccountService") @@ -25,30 +39,32 @@ public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { ) } - @WeakInjectable // TODO AccountReference is internal! - private var account: Account + @AccountReference private var account: Account - public let configuration: UserIdPasswordServiceConfiguration + let configuration: AccountServiceConfiguration private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? - // TODO make this configurable? + private var currentContinuation: CheckedContinuation? + init(passwordValidationRules: [ValidationRule] = [FirebaseEmailPasswordAccountService.defaultPasswordValidationRule]) { - self.configuration = .init( + self.configuration = AccountServiceConfiguration( name: LocalizedStringResource("FIREBASE_EMAIL_AND_PASSWORD", bundle: .atURL(from: .module)), - image: Image(systemName: "envelope.fill"), - signUpRequirements: AccountValueRequirements { - UserIdAccountValueKey.self - PasswordAccountValueKey.self - NameAccountValueKey.self - }, - userIdType: .emailAddress, - userIdField: .emailAddress, - userIdSignupValidations: [.minimalEmailValidationRule], - passwordSignupValidations: passwordValidationRules - ) + supportedKeys: .exactly(Self.supportedKeys) + ) { + AccountServiceImage(Image(systemName: "envelope.fill")) + RequiredAccountKeys { + \.userId + \.password + } + UserIdConfiguration(type: .emailAddress, keyboardType: .emailAddress) + + FieldValidationRules(for: \.userId, rules: .minimalEmail) + FieldValidationRules(for: \.password, rules: passwordValidationRules) + } } - public func configure() { + + func configure() { authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener(stateDidChangeListener) // if there is a cached user, we refresh the authentication token @@ -61,15 +77,11 @@ public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } } - public func login(userId: String, password: String) async throws { + func login(userId: String, password: String) async throws { do { try await Auth.auth().signIn(withEmail: userId, password: password) - // TODO why did we trigger? - // Task { @MainActor in - // account?.objectWillChange.send() - // } - + try await continueWithStateChange() } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -77,29 +89,27 @@ public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } } - public func signUp(signupRequest: SignupRequest) async throws { + func signUp(signupDetails: SignupDetails) async throws { do { - let authResult = try await Auth.auth().createUser(withEmail: signupRequest.userId, password: signupRequest.password) - - let profileChangeRequest = authResult.user.createProfileChangeRequest() - profileChangeRequest.displayName = signupRequest.name.formatted(.name(style: .long)) - try await profileChangeRequest.commitChanges() + let authResult = try await Auth.auth().createUser(withEmail: signupDetails.userId, password: signupDetails.password) - // TODO why did we trigger? - // Task { @MainActor in - // account?.objectWillChange.send() - // } + if let displayName = signupDetails.name?.formatted(.name(style: .long)) { + let changeRequest = authResult.user.createProfileChangeRequest() + changeRequest.displayName = displayName + try await changeRequest.commitChanges() + } try await authResult.user.sendEmailVerification() + + try await continueWithStateChange() } catch let error as NSError { - // TODO can we inline the `invalidEmail` and `weakPassword` errors? => weakPassword has to be updated in the validation rule! throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { throw FirebaseAccountError.unknown(.internalError) } } - public func resetPassword(userId: String) async throws { + func resetPassword(userId: String) async throws { do { try await Auth.auth().sendPasswordReset(withEmail: userId) } catch let error as NSError { @@ -109,11 +119,67 @@ public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } } - public func logout() async throws { + func logout() async throws { + guard Auth.auth().currentUser != nil else { + throw FirebaseAccountError.notSignedIn + } + do { try Auth.auth().signOut() - // TODO verify that this results in the user getting removed? + try await continueWithStateChange() + } catch let error as NSError { + throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + } catch { + throw FirebaseAccountError.unknown(.internalError) + } + } + + func delete() async throws { + guard let currentUser = Auth.auth().currentUser else { + throw FirebaseAccountError.notSignedIn + } + + do { + try await currentUser.delete() + + try await continueWithStateChange() + } catch let error as NSError { + throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + } catch { + throw FirebaseAccountError.unknown(.internalError) + } + } + + func updateAccountDetails(_ modifications: AccountModifications) async throws { + guard let currentUser = Auth.auth().currentUser else { + throw FirebaseAccountError.notSignedIn + } + + var changes = false + + do { + if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { + try await currentUser.updateEmail(to: userId) + changes = true + } + + if let password = modifications.modifiedDetails.password { + try await currentUser.updatePassword(to: password) + changes = true + } + + if let name = modifications.modifiedDetails.name { + let changeRequest = currentUser.createProfileChangeRequest() + changeRequest.displayName = name.formatted(.name(style: .long)) + try await changeRequest.commitChanges() + + changes = true + } + + if changes { + try await continueWithStateChange() + } } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -122,38 +188,71 @@ public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } private func stateDidChangeListener(auth: Auth, user: User?) { - Task { - if let user { - await notifyUserSignIn(user: user) - } else { - await notifyUserRemoval() + // this is called by the FIRAuth framework. + + let result: StateChangeResult + if let user { + result = .user(user) + } else { + result = .removed + } + + // if we have a current continuation waiting for our result, resume there + if let currentContinuation { + currentContinuation.resume(returning: result) + self.currentContinuation = nil + } else { + // Otherwise, there might still be cases where changes are triggered externally. + // We cannot sensibly display any error messages then, though. + Task { + do { + try await updateUser(result) + } catch { + // currently, this only happens if the storage standard fails to load the additional user record + Self.logger.error("Failed to execute remote user change: \(error)") + } } } } - func notifyUserSignIn(user: User) async { - guard let email = user.email, - let displayName = user.displayName else { - // TODO log - return // TODO how to propagate back the error? + func continueWithStateChange() async throws { + let result: StateChangeResult = await withCheckedContinuation { continuation in + self.currentContinuation = continuation } - guard let nameComponents = try? PersonNameComponents(displayName) else { + try await updateUser(result) + } + + func updateUser(_ state: StateChangeResult) async throws { + switch state { + case let .user(user): + try await notifyUserSignIn(user: user) + case .removed: + await notifyUserRemoval() + } + } + + func notifyUserSignIn(user: User) async throws { + guard let email = user.email else { + throw FirebaseAccountError.invalidEmail + } + + let builder = AccountDetails.Builder() + .set(\.userId, value: email) + .set(\.isEmailVerified, value: user.isEmailVerified) + + if let displayName = user.displayName, + let nameComponents = try? PersonNameComponents(displayName) { // we wouldn't be here if we couldn't create the person name components from the given string - // TODO log (still show error somehow?) - return + builder.set(\.name, value: nameComponents) } - let details = AccountDetails.Builder() - .add(UserIdAccountValueKey.self, value: email) - .add(NameAccountValueKey.self, value: nameComponents) - .add(FirebaseEmailVerifiedKey.self, value: user.isEmailVerified) - .build(owner: self) - await account.supplyUserInfo(details) + let details = builder.build(owner: self) + try await account.supplyUserDetails(details) } func notifyUserRemoval() async { - await account.removeUserInfo() + await account.removeUserDetails() } } diff --git a/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings b/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings index c5b80b2..8448176 100644 --- a/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings +++ b/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings @@ -29,5 +29,11 @@ "FIREBASE_ACCOUNT_SETUP_ERROR" = "Benutzerkonto konnte nicht erstelle werden"; "FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" = "Es ist ein Fehler aufgetreten beim erstellen deines Benutzerkontos. Bitte versuche Sie es später erneut."; -"FIREBASE_ACCOUNT_UNKNOWN" = "Benutzerkonto konnte nicht erstelle werden"; -"FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" = "Bitte überprüfen Sie Ihre E-Mail Adresse und versuchen Sie es erneut."; +"FIREBASE_ACCOUNT_SIGN_IN_ERROR" = "Nicht eingeloggt"; +"FIREBASE_ACCOUNT_SIGN_IN_ERROR_SUGGESTION" = "Die Anfrage konnte nicht ausgeführt werden weil kein verknüpftes Benutzerkonto gefunden wurde."; + +"FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR" = "Anfrage fehlgeschlagen"; +"FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR_SUGGESTION" = "Diese Anfrage is sicherheitsrelevant und erfordert einen kürzlichen Login."; + +"FIREBASE_ACCOUNT_UNKNOWN" = "Anfrage fehlgeschlagen"; +"FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" = "Bitte überprüfen deine Internet Verbindung und versuche es erneut."; diff --git a/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings b/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings index 12880dc..c935708 100644 --- a/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings +++ b/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings @@ -29,5 +29,11 @@ "FIREBASE_ACCOUNT_SETUP_ERROR" = "Could not create the user account"; "FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" = "There was an internal error when trying to create your user account. Please try again later."; -"FIREBASE_ACCOUNT_UNKNOWN" = "Could not create the user account"; +"FIREBASE_ACCOUNT_SIGN_IN_ERROR" = "Not signed in"; +"FIREBASE_ACCOUNT_SIGN_IN_ERROR_SUGGESTION" = "Couldn't complete this operation as there is no current user account."; + +"FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR" = "Couldn't complete operation"; +"FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR_SUGGESTION" = "This is a security relevant operation that requires a recent login."; + +"FIREBASE_ACCOUNT_UNKNOWN" = "Failed account operation"; "FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" = "Please check your internet connection and try again."; diff --git a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift index a573e20..58fd019 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -18,22 +18,53 @@ struct FirebaseAccountTestsView: View { @EnvironmentObject var account: Account @State var viewState: ViewState = .idle + + @State var showSetup = false + @State var showOverview = false + @State var isEditing = false var body: some View { List { if let details = account.details { HStack { - UserProfileView(name: details.name) + UserProfileView(name: details.name ?? .init(givenName: "NOT FOUND")) .frame(height: 30) - Text(details.userId) // TODO specific email key? + Text(details.userId) + } + } + Button("Account Setup") { + showSetup = true + } + Button("Account Overview") { + showOverview = true + } + .sheet(isPresented: $showSetup) { + NavigationStack { + AccountSetup() + } + .toolbar { + toolbar(closing: $showSetup) + } } + .sheet(isPresented: $showOverview) { + NavigationStack { + AccountOverview(isEditing: $isEditing) + } + .toolbar { + toolbar(closing: $showOverview) + } + } + } + } + - // TODO rename this thing and move to SpeziViews! - AsyncDataEntrySubmitButton("Logout", state: $viewState) { - try await details.accountService.logout() + @ToolbarContentBuilder + func toolbar(closing flag: Binding) -> some ToolbarContent { + if !isEditing { + ToolbarItemGroup(placement: .cancellationAction) { + Button("Close") { + flag.wrappedValue = false } - } else { - AccountSetup() // TODO external parameter name } } } diff --git a/Tests/UITests/TestApp/GoogleService-Info.plist b/Tests/UITests/TestApp/GoogleService-Info.plist index 743e642..efd4c5e 100644 --- a/Tests/UITests/TestApp/GoogleService-Info.plist +++ b/Tests/UITests/TestApp/GoogleService-Info.plist @@ -13,7 +13,7 @@ PLIST_VERSION 1 BUNDLE_ID - edu.stanford.spezi.firebase + edu.stanford.spezi.firebase.testapp PROJECT_ID spezifirebaseuitests STORAGE_BUCKET diff --git a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift index 2ac812d..c3657e6 100644 --- a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift @@ -7,6 +7,7 @@ // import Spezi +import SpeziAccount import SpeziFirebaseAccount import SpeziFirestore import SwiftUI @@ -15,6 +16,11 @@ import SwiftUI class TestAppDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { + AccountConfiguration(configuration: [ + .requires(\.userId), + .requires(\.password), + .collects(\.name) + ]) Firestore(settings: .emulator) FirebaseAccountConfiguration(emulatorSettings: (host: "localhost", port: 9099)) } diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 833de11..76a0822 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -111,10 +111,10 @@ { "identity" : "speziaccount", "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziAccount", + "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", "state" : { "branch" : "feature/simple-account-view", - "revision" : "9290d37ce2c631e241127662654a37b34de18281" + "revision" : "c3e4300a1486cae0eda552569babc5ddcf3681e6" } }, { @@ -140,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "ce20dc083ee485524b802669890291c0d8090170", - "version" : "1.22.1" + "revision" : "cf62cdaea48b77f1a631e5cb3aeda6047c2cba1d", + "version" : "1.23.0" } }, { diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index f6731e0..fc3757d 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -8,16 +8,16 @@ + BlueprintIdentifier = "2F6D139128F5F384007C25D6" + BuildableName = "TestApp.app" + BlueprintName = "TestApp" + ReferencedContainer = "container:UITests.xcodeproj"> + + Date: Thu, 31 Aug 2023 19:47:52 +0200 Subject: [PATCH 05/13] Update UI Tests --- .swiftlint.yml | 3 -- .../FirebaseAccountTestsView.swift | 20 ++++++++----- .../TestAppUITests/FirebaseAccountTests.swift | 29 ++++++++++--------- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index a2fa581..d423942 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -367,9 +367,6 @@ only_rules: # The variable should be placed on the left, the constant on the right of a comparison operator. - yoda_condition -attributes: - attributes_with_arguments_always_on_line_above: false - deployment_target: # Availability checks or attributes shouldn’t be using older versions that are satisfied by the deployment target. iOSApplicationExtension_deployment_target: 16.0 iOS_deployment_target: 16.0 diff --git a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift index 58fd019..7c9bcb2 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -31,6 +31,10 @@ struct FirebaseAccountTestsView: View { .frame(height: 30) Text(details.userId) } + + AsyncButton("Logout", role: .destructive, state: $viewState) { + try await details.accountService.logout() + } } Button("Account Setup") { showSetup = true @@ -41,26 +45,26 @@ struct FirebaseAccountTestsView: View { .sheet(isPresented: $showSetup) { NavigationStack { AccountSetup() + .toolbar { + toolbar(closing: $showSetup) + } } - .toolbar { - toolbar(closing: $showSetup) - } } .sheet(isPresented: $showOverview) { NavigationStack { AccountOverview(isEditing: $isEditing) + .toolbar { + toolbar(closing: $showOverview, isEditing: $isEditing) + } } - .toolbar { - toolbar(closing: $showOverview) - } } } } @ToolbarContentBuilder - func toolbar(closing flag: Binding) -> some ToolbarContent { - if !isEditing { + func toolbar(closing flag: Binding, isEditing: Binding = .constant(false)) -> some ToolbarContent { + if isEditing.wrappedValue == false { ToolbarItemGroup(placement: .cancellationAction) { Button("Close") { flag.wrappedValue = false diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index 5fb3362..acc25d7 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -245,14 +245,13 @@ final class FirebaseAccountTests: XCTestCase { extension XCUIApplication { fileprivate func login(username: String, password: String) throws { - buttons["Login"].tap() - buttons["Email and Password"].tap() - XCTAssertTrue(self.navigationBars.buttons["Login"].waitForExistence(timeout: 2.0)) + buttons["Account Setup"].tap() + XCTAssertTrue(self.buttons["Login"].waitForExistence(timeout: 2.0)) - try textFields["Enter your email ..."].enter(value: username) + try textFields["E-Mail Address"].enter(value: username) dismissKeyboard() - try secureTextFields["Enter your password ..."].enter(value: password) + try secureTextFields["Password"].enter(value: password) dismissKeyboard() swipeUp() @@ -264,21 +263,22 @@ extension XCUIApplication { } allButtons[index].tap() } + + sleep(3) + buttons["Close"].tap() } fileprivate func signup(username: String, password: String, givenName: String, familyName: String) throws { - buttons["Sign Up"].tap() - buttons["Email and Password"].tap() - XCTAssertTrue(self.navigationBars.buttons["Sign Up"].waitForExistence(timeout: 2.0)) - - try textFields["Enter your email ..."].enter(value: username) - dismissKeyboard() + buttons["Account Setup"].tap() + buttons["Signup"].tap() + + XCTAssertTrue(staticTexts["Please fill out the details below to create a new account."].waitForExistence(timeout: 2.0)) - try secureTextFields["Enter your password ..."].enter(value: password) + try textFields["E-Mail Address"].enter(value: username) dismissKeyboard() - try secureTextFields["Repeat your password ..."].enter(value: password) + try secureTextFields["Password"].enter(value: password) dismissKeyboard() swipeUp() @@ -299,5 +299,8 @@ extension XCUIApplication { } allButtons[index].tap() } + + sleep(3) + buttons["Close"].tap() } } From e788a84ed68f9016bea78c5cef2f6277fe94b6a2 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 1 Sep 2023 00:50:25 +0200 Subject: [PATCH 06/13] Adjust tests --- .../FirebaseEmailPasswordAccountService.swift | 140 +++++++++++++----- .../TestAppUITests/FirebaseAccountTests.swift | 60 ++++++-- .../xcshareddata/xcschemes/TestApp.xcscheme | 2 +- 3 files changed, 148 insertions(+), 54 deletions(-) diff --git a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift index 37797f0..49a8373 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift @@ -12,11 +12,15 @@ import SpeziAccount import SwiftUI -enum StateChangeResult { +private enum UserChange { case user(_ user: User) case removed } +private struct QueueUpdate { + let change: UserChange +} + actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { private static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") @@ -44,7 +48,8 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { let configuration: AccountServiceConfiguration private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? - private var currentContinuation: CheckedContinuation? + private var shouldQueue = false + private var queuedUpdate: QueueUpdate? init(passwordValidationRules: [ValidationRule] = [FirebaseEmailPasswordAccountService.defaultPasswordValidationRule]) { self.configuration = AccountServiceConfiguration( @@ -78,10 +83,19 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } func login(userId: String, password: String) async throws { + Self.logger.debug("Received new login request...") + + defer { + cleanupQueuedChanges() + } + + shouldQueue = true + do { try await Auth.auth().signIn(withEmail: userId, password: password) + Self.logger.debug("signIn(withEmail:password:)") - try await continueWithStateChange() + try await dispatchQueuedChanges() } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -90,18 +104,29 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } func signUp(signupDetails: SignupDetails) async throws { + Self.logger.debug("Received new signup request...") + + defer { + cleanupQueuedChanges() + } + + shouldQueue = true + do { let authResult = try await Auth.auth().createUser(withEmail: signupDetails.userId, password: signupDetails.password) + Self.logger.debug("createUser(withEmail:password:) for user.") - if let displayName = signupDetails.name?.formatted(.name(style: .long)) { + Self.logger.debug("Sending email verification link now...") + try await authResult.user.sendEmailVerification() + + if let displayName = signupDetails.name { + Self.logger.debug("Creating change request for display name.") let changeRequest = authResult.user.createProfileChangeRequest() - changeRequest.displayName = displayName + changeRequest.displayName = displayName.formatted(.name(style: .medium)) try await changeRequest.commitChanges() } - try await authResult.user.sendEmailVerification() - - try await continueWithStateChange() + try await dispatchQueuedChanges() } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -112,6 +137,7 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { func resetPassword(userId: String) async throws { do { try await Auth.auth().sendPasswordReset(withEmail: userId) + Self.logger.debug("sendPasswordReset(withEmail:) for user.") } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -124,10 +150,17 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { throw FirebaseAccountError.notSignedIn } + defer { + cleanupQueuedChanges() + } + + shouldQueue = true + do { try Auth.auth().signOut() + Self.logger.debug("signOut() for user.") - try await continueWithStateChange() + try await dispatchQueuedChanges() } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -140,10 +173,17 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { throw FirebaseAccountError.notSignedIn } + defer { + cleanupQueuedChanges() + } + + shouldQueue = true + do { try await currentUser.delete() + Self.logger.debug("delete() for user.") - try await continueWithStateChange() + try await dispatchQueuedChanges() } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -156,30 +196,29 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { throw FirebaseAccountError.notSignedIn } - var changes = false + defer { + cleanupQueuedChanges() + } do { if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { + Self.logger.debug("updateEmail(to:) for user.") try await currentUser.updateEmail(to: userId) - changes = true } if let password = modifications.modifiedDetails.password { + Self.logger.debug("updatePassword(to:) for user.") try await currentUser.updatePassword(to: password) - changes = true } if let name = modifications.modifiedDetails.name { + Self.logger.debug("Creating change request for updated display name.") let changeRequest = currentUser.createProfileChangeRequest() changeRequest.displayName = name.formatted(.name(style: .long)) try await changeRequest.commitChanges() - - changes = true } - if changes { - try await continueWithStateChange() - } + try await dispatchQueuedChanges() } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -190,41 +229,58 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { private func stateDidChangeListener(auth: Auth, user: User?) { // this is called by the FIRAuth framework. - let result: StateChangeResult + let change: UserChange if let user { - result = .user(user) + change = .user(user) } else { - result = .removed + change = .removed } - // if we have a current continuation waiting for our result, resume there - if let currentContinuation { - currentContinuation.resume(returning: result) - self.currentContinuation = nil + + if shouldQueue { + Self.logger.debug("Received stateDidChange that is queued to be dispatched in active call.") + self.queuedUpdate = QueueUpdate(change: change) } else { - // Otherwise, there might still be cases where changes are triggered externally. - // We cannot sensibly display any error messages then, though. - Task { - do { - try await updateUser(result) - } catch { - // currently, this only happens if the storage standard fails to load the additional user record - Self.logger.error("Failed to execute remote user change: \(error)") - } - } + Self.logger.debug("Received stateDidChange that that was triggered due to other reasons. Dispatching anonymously...") + anonymouslyDispatchChange(change) + } + } + + private func cleanupQueuedChanges() { + shouldQueue = false + + guard let queuedUpdate = self.queuedUpdate else { + return } + + + self.queuedUpdate = nil + anonymouslyDispatchChange(queuedUpdate.change) } - func continueWithStateChange() async throws { - let result: StateChangeResult = await withCheckedContinuation { continuation in - self.currentContinuation = continuation + private func dispatchQueuedChanges() async throws { + shouldQueue = false + + guard let queuedUpdate else { + return } - try await updateUser(result) + try await apply(change: queuedUpdate.change) + self.queuedUpdate = nil + } + + private func anonymouslyDispatchChange(_ change: UserChange) { + Task { + do { + try await apply(change: change) + } catch { + Self.logger.error("Failed to anonymously dispatch user change due to \(error)") + } + } } - func updateUser(_ state: StateChangeResult) async throws { - switch state { + private func apply(change: UserChange) async throws { + switch change { case let .user(user): try await notifyUserSignIn(user: user) case .removed: @@ -247,12 +303,14 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { builder.set(\.name, value: nameComponents) } + Self.logger.debug("Notifying SpeziAccount with updated user details.") let details = builder.build(owner: self) try await account.supplyUserDetails(details) } func notifyUserRemoval() async { + Self.logger.debug("Notifying SpeziAccount of removed user details.") await account.removeUserDetails() } } diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index acc25d7..a606dee 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -103,6 +103,38 @@ final class FirebaseAccountTests: XCTestCase { XCTAssert(app.buttons["Logout"].waitForExistence(timeout: 10.0)) app.buttons["Logout"].tap() } + + @MainActor + func testAccountLogout() async throws { + try await createAccount(email: "test@username0.edu", password: "TestPassword0", displayName: "Test0 Username0") + + let accounts = try await getAllAccounts() + XCTAssertEqual(accounts, [FirestoreAccount(email: "test@username0.edu", displayName: "Test0 Username0")]) + + let app = XCUIApplication() + app.launchArguments = ["--firebaseAccount"] + app.launch() + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + app.buttons["FirebaseAccount"].tap() + + if app.buttons["Logout"].waitForExistence(timeout: 10.0) && app.buttons["Logout"].isHittable { + app.buttons["Logout"].tap() + } + + try app.login(username: "test@username0.edu", password: "TestPassword0") + XCTAssert(app.staticTexts["test@username0.edu"].waitForExistence(timeout: 10.0)) + + app.buttons["Account Overview"].tap() + XCTAssertTrue(app.staticTexts["Test0 Username0"].waitForExistence(timeout: 5.0)) + + app.buttons["Logout"].tap() + // TODO logout button! + } + + // TODO test removal + + // TODO test edit @MainActor @@ -199,11 +231,7 @@ final class FirebaseAccountTests: XCTestCase { let userInfo: [FirestoreAccount] } - do { - return try JSONDecoder().decode(ResponseWrapper.self, from: data).userInfo - } catch { - return [] - } + return try JSONDecoder().decode(ResponseWrapper.self, from: data).userInfo } // curl -H 'Content-Type: application/json' -d '{"email":"[user@example.com]","password":"[PASSWORD]","returnSecureToken":true}' 'http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=spezifirebaseuitests' @@ -244,15 +272,23 @@ final class FirebaseAccountTests: XCTestCase { extension XCUIApplication { + func extendedDismissKeyboard() { + let keyboard = keyboards.firstMatch + + if keyboard.waitForExistence(timeout: 1.0) && keyboard.buttons["Done"].isHittable { + keyboard.buttons["Done"].tap() + } + } + fileprivate func login(username: String, password: String) throws { buttons["Account Setup"].tap() XCTAssertTrue(self.buttons["Login"].waitForExistence(timeout: 2.0)) try textFields["E-Mail Address"].enter(value: username) - dismissKeyboard() + extendedDismissKeyboard() try secureTextFields["Password"].enter(value: password) - dismissKeyboard() + extendedDismissKeyboard() swipeUp() @@ -276,25 +312,25 @@ extension XCUIApplication { XCTAssertTrue(staticTexts["Please fill out the details below to create a new account."].waitForExistence(timeout: 2.0)) try textFields["E-Mail Address"].enter(value: username) - dismissKeyboard() + extendedDismissKeyboard() try secureTextFields["Password"].enter(value: password) - dismissKeyboard() + extendedDismissKeyboard() swipeUp() try textFields["Enter your first name ..."].enter(value: givenName) - dismissKeyboard() + extendedDismissKeyboard() swipeUp() try textFields["Enter your last name ..."].enter(value: familyName) - dismissKeyboard() + extendedDismissKeyboard() swipeUp() let allButtons = collectionViews.buttons.allElementsBoundByIndex for index in 0.. Date: Fri, 1 Sep 2023 17:36:54 +0200 Subject: [PATCH 07/13] Make Login, Logout and Removal tests work --- .../FirebaseAccountTestsView.swift | 30 ++-- .../TestAppUITests/FirebaseAccountTests.swift | 160 +++++++++++------- 2 files changed, 117 insertions(+), 73 deletions(-) diff --git a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift index 7c9bcb2..f5f99ea 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -42,23 +42,23 @@ struct FirebaseAccountTestsView: View { Button("Account Overview") { showOverview = true } - .sheet(isPresented: $showSetup) { - NavigationStack { - AccountSetup() - .toolbar { - toolbar(closing: $showSetup) - } - } + } + .sheet(isPresented: $showSetup) { + NavigationStack { + AccountSetup() + .toolbar { + toolbar(closing: $showSetup) + } } - .sheet(isPresented: $showOverview) { - NavigationStack { - AccountOverview(isEditing: $isEditing) - .toolbar { - toolbar(closing: $showOverview, isEditing: $isEditing) - } - } + } + .sheet(isPresented: $showOverview) { + NavigationStack { + AccountOverview(isEditing: $isEditing) + .toolbar { + toolbar(closing: $showOverview, isEditing: $isEditing) + } } - } + } } diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index a606dee..c1547e5 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -53,18 +53,20 @@ final class FirebaseAccountTests: XCTestCase { .map(\.providerId) } } - + + private static let projectId = "nams-e43ed" // TODO = "spezifirebaseuitests" + @MainActor override func setUp() async throws { try await super.setUp() - try disablePasswordAutofill() + // try disablePasswordAutofill() try await deleteAllAccounts() try await Task.sleep(for: .seconds(0.5)) } - + @MainActor func testAccountSignUp() async throws { @@ -78,7 +80,7 @@ final class FirebaseAccountTests: XCTestCase { var accounts = try await getAllAccounts() XCTAssert(accounts.isEmpty) - if app.buttons["Logout"].waitForExistence(timeout: 10.0) && app.buttons["Logout"].isHittable { + if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { app.buttons["Logout"].tap() } @@ -103,38 +105,6 @@ final class FirebaseAccountTests: XCTestCase { XCTAssert(app.buttons["Logout"].waitForExistence(timeout: 10.0)) app.buttons["Logout"].tap() } - - @MainActor - func testAccountLogout() async throws { - try await createAccount(email: "test@username0.edu", password: "TestPassword0", displayName: "Test0 Username0") - - let accounts = try await getAllAccounts() - XCTAssertEqual(accounts, [FirestoreAccount(email: "test@username0.edu", displayName: "Test0 Username0")]) - - let app = XCUIApplication() - app.launchArguments = ["--firebaseAccount"] - app.launch() - - XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) - app.buttons["FirebaseAccount"].tap() - - if app.buttons["Logout"].waitForExistence(timeout: 10.0) && app.buttons["Logout"].isHittable { - app.buttons["Logout"].tap() - } - - try app.login(username: "test@username0.edu", password: "TestPassword0") - XCTAssert(app.staticTexts["test@username0.edu"].waitForExistence(timeout: 10.0)) - - app.buttons["Account Overview"].tap() - XCTAssertTrue(app.staticTexts["Test0 Username0"].waitForExistence(timeout: 5.0)) - - app.buttons["Logout"].tap() - // TODO logout button! - } - - // TODO test removal - - // TODO test edit @MainActor @@ -174,12 +144,99 @@ final class FirebaseAccountTests: XCTestCase { XCTAssert(app.buttons["Logout"].waitForExistence(timeout: 10.0)) app.buttons["Logout"].tap() } - + + @MainActor + func testAccountLogout() async throws { + try await createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Test Username") + + let accounts = try await getAllAccounts() + XCTAssertEqual(accounts, [FirestoreAccount(email: "test@username.edu", displayName: "Test Username")]) + + let app = XCUIApplication() + app.launchArguments = ["--firebaseAccount"] + app.launch() + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + app.buttons["FirebaseAccount"].tap() + + if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { + app.buttons["Logout"].tap() + } + + try app.login(username: "test@username.edu", password: "TestPassword") + XCTAssert(app.staticTexts["test@username.edu"].waitForExistence(timeout: 10.0)) + + app.buttons["Account Overview"].tap() + XCTAssertTrue(app.staticTexts["test@username.edu"].waitForExistence(timeout: 5.0)) + + let logoutButtons = app.buttons.matching(identifier: "Logout").allElementsBoundByIndex + XCTAssert(!logoutButtons.isEmpty) + logoutButtons.last!.tap() + + let alert = "Are you sure you want to logout?" + XCTAssertTrue(XCUIApplication().alerts[alert].waitForExistence(timeout: 6.0)) + XCUIApplication().alerts[alert].scrollViews.otherElements.buttons["Logout"].tap() + + sleep(2) + let accounts2 = try await getAllAccounts() + XCTAssertEqual( + accounts2.sorted(by: { $0.email < $1.email }), + [ + FirestoreAccount(email: "test@username.edu", displayName: "Test Username") + ] + ) + } + + @MainActor + func testAccountRemoval() async throws { + try await createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Test Username") + + let accounts = try await getAllAccounts() + XCTAssertEqual(accounts, [FirestoreAccount(email: "test@username.edu", displayName: "Test Username")]) + + let app = XCUIApplication() + app.launchArguments = ["--firebaseAccount"] + app.launch() + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + app.buttons["FirebaseAccount"].tap() + + if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { + app.buttons["Logout"].tap() + } + + try app.login(username: "test@username.edu", password: "TestPassword") + XCTAssert(app.staticTexts["test@username.edu"].waitForExistence(timeout: 10.0)) + + app.buttons["Account Overview"].tap() + XCTAssertTrue(app.staticTexts["test@username.edu"].waitForExistence(timeout: 5.0)) + + app.buttons["Edit"].tap() + sleep(1) + if app.buttons["Edit"].waitForExistence(timeout: 1.0) { + app.buttons["Edit"].tap() + } + + + XCTAssertTrue(app.buttons["Delete Account"].waitForExistence(timeout: 4.0)) + app.buttons["Delete Account"].tap() + + let alert = "Are you sure you want to delete your account?" + XCTAssertTrue(XCUIApplication().alerts[alert].waitForExistence(timeout: 6.0)) + XCUIApplication().alerts[alert].scrollViews.otherElements.buttons["Delete"].tap() + + sleep(2) + let accounts2 = try await getAllAccounts() + XCTAssertEqual(accounts2, []) + } + + // TODO test edit + // curl -H "Authorization: Bearer owner" -X DELETE http://localhost:9099/emulator/v1/projects/spezifirebaseuitests/accounts private func deleteAllAccounts() async throws { let emulatorDocumentsURL = try XCTUnwrap( - URL(string: "http://localhost:9099/emulator/v1/projects/spezifirebaseuitests/accounts") + URL(string: "http://localhost:9099/emulator/v1/projects/\(Self.projectId)/accounts") ) var request = URLRequest(url: emulatorDocumentsURL) request.httpMethod = "DELETE" @@ -204,7 +261,7 @@ final class FirebaseAccountTests: XCTestCase { // curl -H "Authorization: Bearer owner" -H "Content-Type: application/json" -X POST -d '{}' http://localhost:9099/identitytoolkit.googleapis.com/v1/projects/spezifirebaseuitests/accounts:query private func getAllAccounts() async throws -> [FirestoreAccount] { let emulatorAccountsURL = try XCTUnwrap( - URL(string: "http://localhost:9099/identitytoolkit.googleapis.com/v1/projects/spezifirebaseuitests/accounts:query") + URL(string: "http://localhost:9099/identitytoolkit.googleapis.com/v1/projects/\(Self.projectId)/accounts:query") ) var request = URLRequest(url: emulatorAccountsURL) request.httpMethod = "POST" @@ -237,7 +294,7 @@ final class FirebaseAccountTests: XCTestCase { // curl -H 'Content-Type: application/json' -d '{"email":"[user@example.com]","password":"[PASSWORD]","returnSecureToken":true}' 'http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=spezifirebaseuitests' private func createAccount(email: String, password: String, displayName: String) async throws { let emulatorAccountsURL = try XCTUnwrap( - URL(string: "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=spezifirebaseuitests") + URL(string: "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=\(Self.projectId)") ) var request = URLRequest(url: emulatorAccountsURL) request.httpMethod = "POST" @@ -291,17 +348,11 @@ extension XCUIApplication { extendedDismissKeyboard() swipeUp() - - let allButtons = scrollViews.buttons.allElementsBoundByIndex - for index in 0.. Date: Sat, 2 Sep 2023 14:16:30 +0200 Subject: [PATCH 08/13] Finalize UI tests and update the implementation --- .../FirebaseAccountConfiguration.swift | 2 +- .../FirebaseAccountError.swift | 4 +- .../FirebaseEmailPasswordAccountService.swift | 43 ++- .../Resources/de.lproj/Localizable.strings | 2 +- .../Resources/en.lproj/Localizable.strings | 2 +- .../TestAppUITests/FirebaseAccountTests.swift | 296 +++++++++--------- .../TestAppUITests/FirebaseClient.swift | 147 +++++++++ .../UITests/UITests.xcodeproj/project.pbxproj | 4 + .../xcshareddata/swiftpm/Package.resolved | 2 +- 9 files changed, 336 insertions(+), 166 deletions(-) create mode 100644 Tests/UITests/TestAppUITests/FirebaseClient.swift diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift index 1b76d54..7b5987b 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift @@ -64,7 +64,7 @@ public final class FirebaseAccountConfiguration: Component { } Task { - // We might be configured above the AccountConfiguration and therfore the `Account` object + // We might be configured above the AccountConfiguration and therefore the `Account` object // might not be injected yet. try? await Task.sleep(for: .milliseconds(10)) for accountService in accountServices { diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift index 0ca8f62..9ec81d9 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift @@ -78,6 +78,8 @@ enum FirebaseAccountError: LocalizedError { init(authErrorCode: AuthErrorCode) { + FirebaseEmailPasswordAccountService.logger.debug("Received authError with code \(authErrorCode)") + switch authErrorCode.code { case .invalidEmail, .invalidRecipientEmail: self = .invalidEmail @@ -85,7 +87,7 @@ enum FirebaseAccountError: LocalizedError { self = .accountAlreadyInUse case .weakPassword: self = .weakPassword - case .userDisabled, .wrongPassword: + case .userDisabled, .wrongPassword, .userNotFound, .userMismatch: self = .invalidCredentials case .invalidSender, .invalidMessagePayload: self = .internalPasswordResetError diff --git a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift index 49a8373..7ed21eb 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift @@ -23,7 +23,7 @@ private struct QueueUpdate { actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { - private static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") + static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") private static let supportedKeys = AccountKeyCollection { \.userId @@ -31,14 +31,16 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { \.name } - static var defaultPasswordValidationRule: ValidationRule { - guard let regex = try? Regex(#"[^\s]{8,}"#) else { - fatalError("Invalid Password Regex in the FirebaseEmailPasswordAccountService") + static var minimumFirebasePassword: ValidationRule { + // Firebase as a non-configurable limit of 6 characters for an account password. + // Refer to https://stackoverflow.com/questions/38064248/firebase-password-validation-allowed-regex + guard let regex = try? Regex(#"(?=.*[0-9a-zA-Z]).{6,}"#) else { + fatalError("Invalid minimumFirebasePassword regex at construction.") } - + return ValidationRule( regex: regex, - message: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR", + message: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR \(6)", bundle: .module ) } @@ -51,7 +53,7 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { private var shouldQueue = false private var queuedUpdate: QueueUpdate? - init(passwordValidationRules: [ValidationRule] = [FirebaseEmailPasswordAccountService.defaultPasswordValidationRule]) { + init(passwordValidationRules: [ValidationRule] = [minimumFirebasePassword]) { self.configuration = AccountServiceConfiguration( name: LocalizedStringResource("FIREBASE_EMAIL_AND_PASSWORD", bundle: .atURL(from: .module)), supportedKeys: .exactly(Self.supportedKeys) @@ -147,7 +149,12 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { func logout() async throws { guard Auth.auth().currentUser != nil else { - throw FirebaseAccountError.notSignedIn + if await account.signedIn { + await notifyUserRemoval() + return + } else { + throw FirebaseAccountError.notSignedIn + } } defer { @@ -170,6 +177,9 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { func delete() async throws { guard let currentUser = Auth.auth().currentUser else { + if await account.signedIn { + await notifyUserRemoval() + } throw FirebaseAccountError.notSignedIn } @@ -193,17 +203,19 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { func updateAccountDetails(_ modifications: AccountModifications) async throws { guard let currentUser = Auth.auth().currentUser else { + if await account.signedIn { + await notifyUserRemoval() + } throw FirebaseAccountError.notSignedIn } - defer { - cleanupQueuedChanges() - } + var changes = false do { if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { Self.logger.debug("updateEmail(to:) for user.") try await currentUser.updateEmail(to: userId) + changes = true } if let password = modifications.modifiedDetails.password { @@ -216,9 +228,14 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { let changeRequest = currentUser.createProfileChangeRequest() changeRequest.displayName = name.formatted(.name(style: .long)) try await changeRequest.commitChanges() + + changes = true } - try await dispatchQueuedChanges() + if changes { + // non of the above request will trigger our state change listener, therefore just call it manually. + try await notifyUserSignIn(user: currentUser) + } } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -298,7 +315,7 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { .set(\.isEmailVerified, value: user.isEmailVerified) if let displayName = user.displayName, - let nameComponents = try? PersonNameComponents(displayName) { + let nameComponents = try? PersonNameComponents(displayName, strategy: .name) { // we wouldn't be here if we couldn't create the person name components from the given string builder.set(\.name, value: nameComponents) } diff --git a/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings b/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings index 8448176..849686c 100644 --- a/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings +++ b/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings @@ -9,7 +9,7 @@ "FIREBASE_EMAIL_AND_PASSWORD" = "E-Mail und Password"; // MARK: ERRORS -"FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR" = "Ein Password muss mindestens acht Zeichen und keine Leerzeichen haben."; +"FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR %lld" = "Dein Passwort muss mindestens %lld Zeichen lang sein."; "FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL" = "Inkorrekte E-Mail Adresse"; "FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL_SUGGESTION" = "Bitte geben Sie eine korrekte E-Mail Adresse ein."; diff --git a/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings b/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings index c935708..6bd38e9 100644 --- a/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings +++ b/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings @@ -9,7 +9,7 @@ "FIREBASE_EMAIL_AND_PASSWORD" = "E-Mail and Password"; // MARK: ERRORS -"FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR" = "A password has to have at least eight characters and no whitespaces."; +"FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR %lld" = "Your password must be at least %lld characters long."; "FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL" = "Invalid email address"; "FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL_SUGGESTION" = "Please enter a valid email address."; diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index c1547e5..db5c8a3 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -15,55 +15,13 @@ import XCTestExtensions /// Refer to https://firebase.google.com/docs/emulator-suite/connect_auth about more information about the /// Firebase Local Emulator Suite. final class FirebaseAccountTests: XCTestCase { - private struct FirestoreAccount: Decodable, Equatable { - enum CodingKeys: String, CodingKey { - case email - case displayName - case providerIds = "providerUserInfo" - } - - - let email: String - let displayName: String - let providerIds: [String] - - - init(email: String, displayName: String, providerIds: [String] = ["password"]) { - self.email = email - self.displayName = displayName - self.providerIds = providerIds - } - - init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container( - keyedBy: FirebaseAccountTests.FirestoreAccount.CodingKeys.self - ) - self.email = try container.decode(String.self, forKey: FirebaseAccountTests.FirestoreAccount.CodingKeys.email) - self.displayName = try container.decode(String.self, forKey: FirebaseAccountTests.FirestoreAccount.CodingKeys.displayName) - - struct ProviderUserInfo: Decodable { - let providerId: String - } - - self.providerIds = try container - .decode( - [ProviderUserInfo].self, - forKey: FirebaseAccountTests.FirestoreAccount.CodingKeys.providerIds - ) - .map(\.providerId) - } - } - - private static let projectId = "nams-e43ed" // TODO = "spezifirebaseuitests" - - @MainActor override func setUp() async throws { try await super.setUp() - // try disablePasswordAutofill() - - try await deleteAllAccounts() + try disablePasswordAutofill() + + try await FirebaseClient.deleteAllAccounts() try await Task.sleep(for: .seconds(0.5)) } @@ -77,7 +35,7 @@ final class FirebaseAccountTests: XCTestCase { XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) app.buttons["FirebaseAccount"].tap() - var accounts = try await getAllAccounts() + var accounts = try await FirebaseClient.getAllAccounts() XCTAssert(accounts.isEmpty) if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { @@ -93,7 +51,7 @@ final class FirebaseAccountTests: XCTestCase { try await Task.sleep(for: .seconds(0.5)) - accounts = try await getAllAccounts() + accounts = try await FirebaseClient.getAllAccounts() XCTAssertEqual( accounts.sorted(by: { $0.email < $1.email }), [ @@ -109,10 +67,10 @@ final class FirebaseAccountTests: XCTestCase { @MainActor func testAccountLogin() async throws { - try await createAccount(email: "test@username1.edu", password: "TestPassword1", displayName: "Test1 Username1") - try await createAccount(email: "test@username2.edu", password: "TestPassword2", displayName: "Test2 Username2") + try await FirebaseClient.createAccount(email: "test@username1.edu", password: "TestPassword1", displayName: "Test1 Username1") + try await FirebaseClient.createAccount(email: "test@username2.edu", password: "TestPassword2", displayName: "Test2 Username2") - let accounts = try await getAllAccounts() + let accounts = try await FirebaseClient.getAllAccounts() XCTAssertEqual( accounts.sorted(by: { $0.email < $1.email }), [ @@ -147,9 +105,9 @@ final class FirebaseAccountTests: XCTestCase { @MainActor func testAccountLogout() async throws { - try await createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Test Username") + try await FirebaseClient.createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Test Username") - let accounts = try await getAllAccounts() + let accounts = try await FirebaseClient.getAllAccounts() XCTAssertEqual(accounts, [FirestoreAccount(email: "test@username.edu", displayName: "Test Username")]) let app = XCUIApplication() @@ -171,14 +129,14 @@ final class FirebaseAccountTests: XCTestCase { let logoutButtons = app.buttons.matching(identifier: "Logout").allElementsBoundByIndex XCTAssert(!logoutButtons.isEmpty) - logoutButtons.last!.tap() + logoutButtons.last!.tap() // swiftlint:disable:this force_unwrapping let alert = "Are you sure you want to logout?" XCTAssertTrue(XCUIApplication().alerts[alert].waitForExistence(timeout: 6.0)) XCUIApplication().alerts[alert].scrollViews.otherElements.buttons["Logout"].tap() sleep(2) - let accounts2 = try await getAllAccounts() + let accounts2 = try await FirebaseClient.getAllAccounts() XCTAssertEqual( accounts2.sorted(by: { $0.email < $1.email }), [ @@ -189,9 +147,9 @@ final class FirebaseAccountTests: XCTestCase { @MainActor func testAccountRemoval() async throws { - try await createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Test Username") + try await FirebaseClient.createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Test Username") - let accounts = try await getAllAccounts() + let accounts = try await FirebaseClient.getAllAccounts() XCTAssertEqual(accounts, [FirestoreAccount(email: "test@username.edu", displayName: "Test Username")]) let app = XCUIApplication() @@ -226,104 +184,143 @@ final class FirebaseAccountTests: XCTestCase { XCUIApplication().alerts[alert].scrollViews.otherElements.buttons["Delete"].tap() sleep(2) - let accounts2 = try await getAllAccounts() - XCTAssertEqual(accounts2, []) + let accountsNew = try await FirebaseClient.getAllAccounts() + XCTAssertEqual(accountsNew, []) } - // TODO test edit + @MainActor + func testAccountEdit() async throws { + try await FirebaseClient.createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Username Test") - - // curl -H "Authorization: Bearer owner" -X DELETE http://localhost:9099/emulator/v1/projects/spezifirebaseuitests/accounts - private func deleteAllAccounts() async throws { - let emulatorDocumentsURL = try XCTUnwrap( - URL(string: "http://localhost:9099/emulator/v1/projects/\(Self.projectId)/accounts") - ) - var request = URLRequest(url: emulatorDocumentsURL) - request.httpMethod = "DELETE" - request.addValue("Bearer owner", forHTTPHeaderField: "Authorization") - - let (_, response) = try await URLSession.shared.data(for: request) - - guard let urlResponse = response as? HTTPURLResponse, - 200...299 ~= urlResponse.statusCode else { - print( - """ - The `FirebaseAccountTests` require the Firebase Authentication Emulator to run at port 9099. - - Refer to https://firebase.google.com/docs/emulator-suite/connect_auth about more information about the - Firebase Local Emulator Suite. - """ - ) - throw URLError(.fileDoesNotExist) + let accounts = try await FirebaseClient.getAllAccounts() + XCTAssertEqual(accounts, [FirestoreAccount(email: "test@username.edu", displayName: "Username Test")]) + + let app = XCUIApplication() + app.launchArguments = ["--firebaseAccount"] + app.launch() + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + app.buttons["FirebaseAccount"].tap() + + if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { + app.buttons["Logout"].tap() } + + try app.login(username: "test@username.edu", password: "TestPassword") + XCTAssert(app.staticTexts["test@username.edu"].waitForExistence(timeout: 10.0)) + + app.buttons["Account Overview"].tap() + XCTAssertTrue(app.staticTexts["test@username.edu"].waitForExistence(timeout: 5.0)) + + app.buttons["Name, E-Mail Address"].tap() + XCTAssertTrue(app.navigationBars.staticTexts["Name, E-Mail Address"].waitForExistence(timeout: 10.0)) + + // CHANGE NAME + app.buttons["Name, Username Test"].tap() + XCTAssertTrue(app.navigationBars.staticTexts["Name"].waitForExistence(timeout: 10.0)) + + try app.textFields["Enter your last name ..."].delete(count: 4) + app.typeText("Test1") + + app.buttons["Done"].tap() + sleep(3) + XCTAssertTrue(app.staticTexts["Username Test1"].waitForExistence(timeout: 5.0)) + + // CHANGE EMAIL ADDRESS + app.buttons["E-Mail Address, test@username.edu"].tap() + XCTAssertTrue(app.navigationBars.staticTexts["E-Mail Address"].waitForExistence(timeout: 10.0)) + + try app.textFields["E-Mail Address"].delete(count: 3) + app.typeText("de") + + app.buttons["Done"].tap() + sleep(3) + XCTAssertTrue(app.staticTexts["test@username.de"].waitForExistence(timeout: 5.0)) + + + let newAccounts = try await FirebaseClient.getAllAccounts() + XCTAssertEqual(newAccounts, [FirestoreAccount(email: "test@username.de", displayName: "Username Test1")]) } - // curl -H "Authorization: Bearer owner" -H "Content-Type: application/json" -X POST -d '{}' http://localhost:9099/identitytoolkit.googleapis.com/v1/projects/spezifirebaseuitests/accounts:query - private func getAllAccounts() async throws -> [FirestoreAccount] { - let emulatorAccountsURL = try XCTUnwrap( - URL(string: "http://localhost:9099/identitytoolkit.googleapis.com/v1/projects/\(Self.projectId)/accounts:query") - ) - var request = URLRequest(url: emulatorAccountsURL) - request.httpMethod = "POST" - request.addValue("Bearer owner", forHTTPHeaderField: "Authorization") - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = Data("{}".utf8) - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let urlResponse = response as? HTTPURLResponse, - 200...299 ~= urlResponse.statusCode else { - print( - """ - The `FirebaseAccountTests` require the Firebase Authentication Emulator to run at port 9099. - - Refer to https://firebase.google.com/docs/emulator-suite/connect_auth about more information about the - Firebase Local Emulator Suite. - """ - ) - throw URLError(.fileDoesNotExist) - } - struct ResponseWrapper: Decodable { - let userInfo: [FirestoreAccount] + @MainActor + func testPasswordChange() async throws { + try await FirebaseClient.createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Username Test") + + let accounts = try await FirebaseClient.getAllAccounts() + XCTAssertEqual(accounts, [FirestoreAccount(email: "test@username.edu", displayName: "Username Test")]) + + let app = XCUIApplication() + app.launchArguments = ["--firebaseAccount"] + app.launch() + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + app.buttons["FirebaseAccount"].tap() + + if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { + app.buttons["Logout"].tap() } - return try JSONDecoder().decode(ResponseWrapper.self, from: data).userInfo + try app.login(username: "test@username.edu", password: "TestPassword") + XCTAssert(app.staticTexts["test@username.edu"].waitForExistence(timeout: 10.0)) + + app.buttons["Account Overview"].tap() + XCTAssertTrue(app.staticTexts["test@username.edu"].waitForExistence(timeout: 5.0)) + + app.buttons["Password & Security"].tap() + XCTAssertTrue(app.navigationBars.staticTexts["Password & Security"].waitForExistence(timeout: 10.0)) + + app.buttons["Change Password"].tap() + XCTAssertTrue(app.navigationBars.staticTexts["Change Password"].waitForExistence(timeout: 10.0)) + sleep(2) + + try app.secureTextFields["New Password"].enter(value: "1234567890") + app.dismissKeyboard() + + try app.secureTextFields["Repeat Password"].enter(value: "1234567890") + app.dismissKeyboard() + + app.buttons["Done"].tap() + sleep(1) + app.navigationBars.buttons["Account Overview"].tap() // back button + sleep(1) + app.buttons["Close"].tap() + sleep(1) + app.buttons["Logout"].tap() // we tap the custom button to be lest dependent on the other tests and not deal with the alert + + try app.login(username: "test@username.edu", password: "1234567890", close: false) + XCTAssertTrue(app.staticTexts["Username Test"].waitForExistence(timeout: 6.0)) } - - // curl -H 'Content-Type: application/json' -d '{"email":"[user@example.com]","password":"[PASSWORD]","returnSecureToken":true}' 'http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=spezifirebaseuitests' - private func createAccount(email: String, password: String, displayName: String) async throws { - let emulatorAccountsURL = try XCTUnwrap( - URL(string: "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=\(Self.projectId)") - ) - var request = URLRequest(url: emulatorAccountsURL) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = Data( - """ - { - "email": "\(email)", - "password": "\(password)", - "displayName": "\(displayName)", - "returnSecureToken": true - } - """.utf8 - ) - - let (_, response) = try await URLSession.shared.data(for: request) - - guard let urlResponse = response as? HTTPURLResponse, - 200...299 ~= urlResponse.statusCode else { - print( - """ - The `FirebaseAccountTests` require the Firebase Authentication Emulator to run at port 9099. - - Refer to https://firebase.google.com/docs/emulator-suite/connect_auth about more information about the - Firebase Local Emulator Suite. - """ - ) - throw URLError(.fileDoesNotExist) + + @MainActor + func testInvalidCredentials() async throws { + try await FirebaseClient.createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Username Test") + + let accounts = try await FirebaseClient.getAllAccounts() + XCTAssertEqual(accounts, [FirestoreAccount(email: "test@username.edu", displayName: "Username Test")]) + + let app = XCUIApplication() + app.launchArguments = ["--firebaseAccount"] + app.launch() + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + app.buttons["FirebaseAccount"].tap() + + if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { + app.buttons["Logout"].tap() } + + try app.login(username: "unknown@example.de", password: "HelloWorld", close: false) + XCTAssertTrue(app.alerts["Invalid Credentials"].waitForExistence(timeout: 6.0)) + app.alerts["Invalid Credentials"].scrollViews.otherElements.buttons["OK"].tap() + app.buttons["Close"].tap() + sleep(2) + + // signing in with unknown credentials or credentials with a incorrect password are two different errors + // that should, nonetheless, be treated equally in UI. + try app.login(username: "test@username.edu", password: "HelloWorld", close: false) + XCTAssertTrue(app.alerts["Invalid Credentials"].waitForExistence(timeout: 6.0)) + app.alerts["Invalid Credentials"].scrollViews.otherElements.buttons["OK"].tap() } } @@ -337,7 +334,7 @@ extension XCUIApplication { } } - fileprivate func login(username: String, password: String) throws { + fileprivate func login(username: String, password: String, close: Bool = true) throws { buttons["Account Setup"].tap() XCTAssertTrue(self.buttons["Login"].waitForExistence(timeout: 2.0)) @@ -351,8 +348,10 @@ extension XCUIApplication { scrollViews.buttons["Login"].tap() - sleep(3) - self.buttons["Close"].tap() + if close { + sleep(3) + self.buttons["Close"].tap() + } } @@ -360,8 +359,9 @@ extension XCUIApplication { buttons["Account Setup"].tap() buttons["Signup"].tap() - XCTAssertTrue(staticTexts["Please fill out the details below to create a new account."].waitForExistence(timeout: 2.0)) - + XCTAssertTrue(staticTexts["Please fill out the details below to create a new account."].waitForExistence(timeout: 6.0)) + sleep(2) + try textFields["E-Mail Address"].enter(value: username) extendedDismissKeyboard() diff --git a/Tests/UITests/TestAppUITests/FirebaseClient.swift b/Tests/UITests/TestAppUITests/FirebaseClient.swift new file mode 100644 index 0000000..f974d3a --- /dev/null +++ b/Tests/UITests/TestAppUITests/FirebaseClient.swift @@ -0,0 +1,147 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +struct FirestoreAccount: Decodable, Equatable { + enum CodingKeys: String, CodingKey { + case email + case displayName + case providerIds = "providerUserInfo" + } + + + let email: String + let displayName: String + let providerIds: [String] + + + init(email: String, displayName: String, providerIds: [String] = ["password"]) { + self.email = email + self.displayName = displayName + self.providerIds = providerIds + } + + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container( + keyedBy: FirebaseAccountTests.FirestoreAccount.CodingKeys.self + ) + self.email = try container.decode(String.self, forKey: FirebaseAccountTests.FirestoreAccount.CodingKeys.email) + self.displayName = try container.decode(String.self, forKey: FirebaseAccountTests.FirestoreAccount.CodingKeys.displayName) + + struct ProviderUserInfo: Decodable { + let providerId: String + } + + self.providerIds = try container + .decode( + [ProviderUserInfo].self, + forKey: FirebaseAccountTests.FirestoreAccount.CodingKeys.providerIds + ) + .map(\.providerId) + } +} + + +enum FirebaseClient { + private static let projectId = "spezifirebaseuitests" + + // curl -H "Authorization: Bearer owner" -X DELETE http://localhost:9099/emulator/v1/projects/spezifirebaseuitests/accounts + static func deleteAllAccounts() async throws { + let emulatorDocumentsURL = try XCTUnwrap( + URL(string: "http://localhost:9099/emulator/v1/projects/\(projectId)/accounts") + ) + var request = URLRequest(url: emulatorDocumentsURL) + request.httpMethod = "DELETE" + request.addValue("Bearer owner", forHTTPHeaderField: "Authorization") + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let urlResponse = response as? HTTPURLResponse, + 200...299 ~= urlResponse.statusCode else { + print( + """ + The `FirebaseAccountTests` require the Firebase Authentication Emulator to run at port 9099. + + Refer to https://firebase.google.com/docs/emulator-suite/connect_auth about more information about the + Firebase Local Emulator Suite. + """ + ) + throw URLError(.fileDoesNotExist) + } + } + + // curl -H "Authorization: Bearer owner" -H "Content-Type: application/json" -X POST -d '{}' http://localhost:9099/identitytoolkit.googleapis.com/v1/projects/spezifirebaseuitests/accounts:query + static func getAllAccounts() async throws -> [FirestoreAccount] { + let emulatorAccountsURL = try XCTUnwrap( + URL(string: "http://localhost:9099/identitytoolkit.googleapis.com/v1/projects/\(projectId)/accounts:query") + ) + var request = URLRequest(url: emulatorAccountsURL) + request.httpMethod = "POST" + request.addValue("Bearer owner", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = Data("{}".utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let urlResponse = response as? HTTPURLResponse, + 200...299 ~= urlResponse.statusCode else { + print( + """ + The `FirebaseAccountTests` require the Firebase Authentication Emulator to run at port 9099. + + Refer to https://firebase.google.com/docs/emulator-suite/connect_auth about more information about the + Firebase Local Emulator Suite. + """ + ) + throw URLError(.fileDoesNotExist) + } + + struct ResponseWrapper: Decodable { + let userInfo: [FirestoreAccount] + } + + return try JSONDecoder().decode(ResponseWrapper.self, from: data).userInfo + } + + // curl -H 'Content-Type: application/json' -d '{"email":"[user@example.com]","password":"[PASSWORD]","returnSecureToken":true}' 'http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=spezifirebaseuitests' + static func createAccount(email: String, password: String, displayName: String) async throws { + let emulatorAccountsURL = try XCTUnwrap( + URL(string: "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=\(projectId)") + ) + var request = URLRequest(url: emulatorAccountsURL) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = Data( + """ + { + "email": "\(email)", + "password": "\(password)", + "displayName": "\(displayName)", + "returnSecureToken": true + } + """.utf8 + ) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let urlResponse = response as? HTTPURLResponse, + 200...299 ~= urlResponse.statusCode else { + print( + """ + The `FirebaseAccountTests` require the Firebase Authentication Emulator to run at port 9099. + + Refer to https://firebase.google.com/docs/emulator-suite/connect_auth about more information about the + Firebase Local Emulator Suite. + """ + ) + throw URLError(.fileDoesNotExist) + } + } +} diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 9f7e007..77e4a54 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 2FB07597299DF96E00C0B37F /* SpeziFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB07596299DF96E00C0B37F /* SpeziFirestore */; }; 2FB0759D299DF96E00C0B37F /* Spezi in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB0759C299DF96E00C0B37F /* Spezi */; }; 2FE62C3D2966074F00FCBE7F /* FirestoreDataStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE62C3C2966074F00FCBE7F /* FirestoreDataStorageTests.swift */; }; + A95D60D02AA35E2200EB5968 /* FirebaseClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95D60CF2AA35E2200EB5968 /* FirebaseClient.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -51,6 +52,7 @@ 2FB926E42974B0FC008E7B03 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 2FC42FD7290ADD5E00B08F18 /* SpeziFirebase */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SpeziFirebase; path = ../..; sourceTree = ""; }; 2FE62C3C2966074F00FCBE7F /* FirestoreDataStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirestoreDataStorageTests.swift; sourceTree = ""; }; + A95D60CF2AA35E2200EB5968 /* FirebaseClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseClient.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -127,6 +129,7 @@ 2FB926E42974B0FC008E7B03 /* Info.plist */, 2F8CE160298C2C6D003799A8 /* FirebaseAccountTests.swift */, 2F2E4B8329749C5900FF710F /* FirestoreDataStorageTests.swift */, + A95D60CF2AA35E2200EB5968 /* FirebaseClient.swift */, ); path = TestAppUITests; sourceTree = ""; @@ -284,6 +287,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A95D60D02AA35E2200EB5968 /* FirebaseClient.swift in Sources */, 2F2E4B8429749C5900FF710F /* FirestoreDataStorageTests.swift in Sources */, 2F8CE161298C2C6D003799A8 /* FirebaseAccountTests.swift in Sources */, ); diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 76a0822..ebe76cb 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -114,7 +114,7 @@ "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", "state" : { "branch" : "feature/simple-account-view", - "revision" : "c3e4300a1486cae0eda552569babc5ddcf3681e6" + "revision" : "7baa04af5d829a762f875f2e729e04ed7a04fbc5" } }, { From f724f67d5cd2795a4518dc1facb5ae01e500ac0a Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sat, 2 Sep 2023 14:17:45 +0200 Subject: [PATCH 09/13] Fix compilation of tests --- Tests/UITests/TestAppUITests/FirebaseClient.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Tests/UITests/TestAppUITests/FirebaseClient.swift b/Tests/UITests/TestAppUITests/FirebaseClient.swift index f974d3a..019f415 100644 --- a/Tests/UITests/TestAppUITests/FirebaseClient.swift +++ b/Tests/UITests/TestAppUITests/FirebaseClient.swift @@ -7,6 +7,7 @@ // import Foundation +import XCTest struct FirestoreAccount: Decodable, Equatable { @@ -29,11 +30,11 @@ struct FirestoreAccount: Decodable, Equatable { } init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container( - keyedBy: FirebaseAccountTests.FirestoreAccount.CodingKeys.self + let container: KeyedDecodingContainer = try decoder.container( + keyedBy: FirestoreAccount.CodingKeys.self ) - self.email = try container.decode(String.self, forKey: FirebaseAccountTests.FirestoreAccount.CodingKeys.email) - self.displayName = try container.decode(String.self, forKey: FirebaseAccountTests.FirestoreAccount.CodingKeys.displayName) + self.email = try container.decode(String.self, forKey: FirestoreAccount.CodingKeys.email) + self.displayName = try container.decode(String.self, forKey: FirestoreAccount.CodingKeys.displayName) struct ProviderUserInfo: Decodable { let providerId: String @@ -42,7 +43,7 @@ struct FirestoreAccount: Decodable, Equatable { self.providerIds = try container .decode( [ProviderUserInfo].self, - forKey: FirebaseAccountTests.FirestoreAccount.CodingKeys.providerIds + forKey: FirestoreAccount.CodingKeys.providerIds ) .map(\.providerId) } From fe4b73171cca318c8d01ff4c383c96d5c35aef58 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 14 Sep 2023 23:02:29 +0200 Subject: [PATCH 10/13] Upgrade spezi account and spezi views --- .../TestAppUITests/FirebaseAccountTests.swift | 14 +++++++------- Tests/UITests/UITests.xcodeproj/project.pbxproj | 4 ++-- .../xcshareddata/swiftpm/Package.resolved | 14 +++++++------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index db5c8a3..517c789 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -219,8 +219,8 @@ final class FirebaseAccountTests: XCTestCase { app.buttons["Name, Username Test"].tap() XCTAssertTrue(app.navigationBars.staticTexts["Name"].waitForExistence(timeout: 10.0)) - try app.textFields["Enter your last name ..."].delete(count: 4) - app.typeText("Test1") + try app.textFields["enter last name"].delete(count: 4) + try app.textFields["enter last name"].enter(value: "Test1") app.buttons["Done"].tap() sleep(3) @@ -231,7 +231,7 @@ final class FirebaseAccountTests: XCTestCase { XCTAssertTrue(app.navigationBars.staticTexts["E-Mail Address"].waitForExistence(timeout: 10.0)) try app.textFields["E-Mail Address"].delete(count: 3) - app.typeText("de") + try app.textFields["E-Mail Address"].enter(value: "de", checkIfTextWasEnteredCorrectly: false) app.buttons["Done"].tap() sleep(3) @@ -274,10 +274,10 @@ final class FirebaseAccountTests: XCTestCase { XCTAssertTrue(app.navigationBars.staticTexts["Change Password"].waitForExistence(timeout: 10.0)) sleep(2) - try app.secureTextFields["New Password"].enter(value: "1234567890") + try app.secureTextFields["enter password"].enter(value: "1234567890") app.dismissKeyboard() - try app.secureTextFields["Repeat Password"].enter(value: "1234567890") + try app.secureTextFields["re-enter password"].enter(value: "1234567890") app.dismissKeyboard() app.buttons["Done"].tap() @@ -370,11 +370,11 @@ extension XCUIApplication { swipeUp() - try textFields["Enter your first name ..."].enter(value: givenName) + try textFields["enter first name"].enter(value: givenName) extendedDismissKeyboard() swipeUp() - try textFields["Enter your last name ..."].enter(value: familyName) + try textFields["enter last name"].enter(value: familyName) extendedDismissKeyboard() swipeUp() diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 77e4a54..fba2c1d 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -578,7 +578,7 @@ repositoryURL = "https://github.com/StanfordSpezi/SpeziViews.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.4.0; + minimumVersion = 0.5.0; }; }; 2F746D9D29962B2A00BF54FE /* XCRemoteSwiftPackageReference "XCTestExtensions" */ = { @@ -586,7 +586,7 @@ repositoryURL = "https://github.com/StanfordSpezi/XCTestExtensions"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.4.6; + minimumVersion = 0.4.7; }; }; 2FB0758B299DF8F100C0B37F /* XCRemoteSwiftPackageReference "Spezi" */ = { diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ebe76cb..b25ca80 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "2bfe6abe1014aafe5cf28401708f7d39f9926a76", - "version" : "10.14.0" + "revision" : "8a8ec57a272e0d31480fb0893dda0cf4f769b57e", + "version" : "10.15.0" } }, { @@ -114,7 +114,7 @@ "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", "state" : { "branch" : "feature/simple-account-view", - "revision" : "7baa04af5d829a762f875f2e729e04ed7a04fbc5" + "revision" : "e13b1f22d4b67527a0515f42071e010bf23cb732" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziViews.git", "state" : { - "revision" : "3131708f262064231751a8963eb263f8648b6879", - "version" : "0.4.1" + "revision" : "626914fa7554aa2b994257541c0794eb930520ba", + "version" : "0.5.0" } }, { @@ -149,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/XCTestExtensions", "state" : { - "revision" : "625477e0937294cb3fd6e7bbf72b78f951644b1d", - "version" : "0.4.6" + "revision" : "388a6d6a5be48eff5d98a2c45e0b50f30ed21dc3", + "version" : "0.4.7" } }, { From 8f114c9cd924b1e074f2f55117430b99cb2fb432 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 14 Sep 2023 23:16:21 +0200 Subject: [PATCH 11/13] Upgrade to SpeziAccount 0.5.0 --- Package.swift | 2 +- Tests/UITests/UITests.xcodeproj/project.pbxproj | 4 ++-- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Package.swift b/Package.swift index fc888e6..153e34f 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.7.0")), - .package(url: "https://github.com/StanfordSpezi/SpeziAccount", branch: "feature/simple-account-view"), + .package(url: "https://github.com/StanfordSpezi/SpeziAccount", .upToNextMinor(from: "0.5.0")), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") ], targets: [ diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index fba2c1d..8d0885e 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -569,8 +569,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordSpezi/SpeziAccount.git"; requirement = { - branch = "feature/simple-account-view"; - kind = branch; + kind = upToNextMinorVersion; + minimumVersion = 0.5.0; }; }; 2F2E8EFD29E7369B00D439B7 /* XCRemoteSwiftPackageReference "SpeziViews" */ = { diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b25ca80..c09761d 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -113,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", "state" : { - "branch" : "feature/simple-account-view", - "revision" : "e13b1f22d4b67527a0515f42071e010bf23cb732" + "revision" : "040aec4d6cf60c2a0f566dbef6f357dc74847507", + "version" : "0.5.0" } }, { From 3c17d6e54e6c3aebd14832ac0b40e4ddabe14700 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 15 Sep 2023 00:01:33 +0200 Subject: [PATCH 12/13] Integrate SpeziSecureStorage to workaround reauthentication on credential edit --- Package.swift | 2 + .../FirebaseAccountConfiguration.swift | 4 +- .../FirebaseEmailPasswordAccountService.swift | 64 ++++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 9 +++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 153e34f..8f47725 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.7.0")), .package(url: "https://github.com/StanfordSpezi/SpeziAccount", .upToNextMinor(from: "0.5.0")), + .package(url: "https://github.com/StanfordSpezi/SpeziStorage", .upToNextMinor(from: "0.4.2")), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") ], targets: [ @@ -34,6 +35,7 @@ let package = Package( .target(name: "SpeziFirebaseConfiguration"), .product(name: "Spezi", package: "Spezi"), .product(name: "SpeziAccount", package: "SpeziAccount"), + .product(name: "SpeziSecureStorage", package: "SpeziStorage"), .product(name: "FirebaseAuth", package: "firebase-ios-sdk") ] ), diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift index 7b5987b..b80e339 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift @@ -14,6 +14,7 @@ import protocol FirebaseAuth.AuthStateDidChangeListenerHandle import FirebaseCore import Foundation import SpeziFirebaseConfiguration +import SpeziSecureStorage /// Configures Firebase Auth `AccountService`s that can be used in any views of the `Account` module. @@ -36,6 +37,7 @@ import SpeziFirebaseConfiguration /// ``` public final class FirebaseAccountConfiguration: Component { @Dependency private var configureFirebaseApp: ConfigureFirebaseApp + @Dependency private var secureStorage: SecureStorage private let emulatorSettings: (host: String, port: Int)? private let authenticationMethods: FirebaseAuthAuthenticationMethods @@ -68,7 +70,7 @@ public final class FirebaseAccountConfiguration: Component { // might not be injected yet. try? await Task.sleep(for: .milliseconds(10)) for accountService in accountServices { - await (accountService as? FirebaseEmailPasswordAccountService)?.configure() + await (accountService as? FirebaseEmailPasswordAccountService)?.configure(with: secureStorage) } } } diff --git a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift index 7ed21eb..f1b00aa 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift @@ -9,6 +9,7 @@ import FirebaseAuth import OSLog import SpeziAccount +import SpeziSecureStorage import SwiftUI @@ -46,6 +47,7 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } @AccountReference private var account: Account + private var secureStorage: SecureStorage? let configuration: AccountServiceConfiguration private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? @@ -71,7 +73,7 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } - func configure() { + func configure(with secureStorage: SecureStorage) { authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener(stateDidChangeListener) // if there is a cached user, we refresh the authentication token @@ -98,6 +100,8 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { Self.logger.debug("signIn(withEmail:password:)") try await dispatchQueuedChanges() + + persistCurrentCredentials(userId: userId, password: password) } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -129,6 +133,8 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } try await dispatchQueuedChanges() + + persistCurrentCredentials(userId: signupDetails.userId, password: signupDetails.password) } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -211,6 +217,13 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { var changes = false + // if we modify sensitive credentials and require a recent login + if modifications.modifiedDetails.storage[UserIdKey.self] != nil || modifications.modifiedDetails.password != nil, + let userId = currentUser.email { + // with a future version of SpeziAccount we want to get rid of this workaround and request the password from the user on the fly. + await reauthenticateUser(userId: userId, user: currentUser) + } + do { if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { Self.logger.debug("updateEmail(to:) for user.") @@ -221,6 +234,10 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { if let password = modifications.modifiedDetails.password { Self.logger.debug("updatePassword(to:) for user.") try await currentUser.updatePassword(to: password) + + if let userId = currentUser.email { // make sure we save the new password + persistCurrentCredentials(userId: userId, password: password) + } } if let name = modifications.modifiedDetails.name { @@ -328,6 +345,51 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { func notifyUserRemoval() async { Self.logger.debug("Notifying SpeziAccount of removed user details.") + let userId = await account.details?.userId + await account.removeUserDetails() + + if let userId { + removeCredentials(userId: userId) + } + } + + func persistCurrentCredentials(userId: String, password: String) { + let passwordCredential = Credentials(username: userId, password: password) + do { + try secureStorage?.store(credentials: passwordCredential, server: "account.firebase.stanford.edu", storageScope: .keychain) + } catch { + Self.logger.debug("Failed to persists login credentials: \(error)") + } + } + + func removeCredentials(userId: String) { + do { + try secureStorage?.deleteCredentials(userId, server: "account.firebase.stanford.edu") + } catch { + Self.logger.debug("Failed to remove credentials: \(error)") + } + } + + func retrieveCredential(userId: String) -> String? { + do { + return try secureStorage?.retrieveCredentials(userId, server: "account.firebase.stanford.edu")?.password + } catch { + Self.logger.debug("Failed to retrieve credentials: \(error)") + } + + return nil + } + + func reauthenticateUser(userId: String, user: User) async { + guard let password = retrieveCredential(userId: userId) else { + return // nothing we can do + } + + do { + try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: userId, password: password)) + } catch { + Self.logger.debug("Credential change might fail. Failed to reauthenticate with firebase.: \(error)") + } } } diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c09761d..78c278b 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -117,6 +117,15 @@ "version" : "0.5.0" } }, + { + "identity" : "spezistorage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/StanfordSpezi/SpeziStorage", + "state" : { + "revision" : "739ee1eadc5f9b1b5d2e8752ba077243d42fa79f", + "version" : "0.4.2" + } + }, { "identity" : "speziviews", "kind" : "remoteSourceControl", From d973b84c1960bee9f41d1cdf3b1620184e62b357 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 15 Sep 2023 00:21:42 +0200 Subject: [PATCH 13/13] Add password reset tests --- .../FirebaseEmailPasswordAccountService.swift | 8 ++++- .../TestAppUITests/FirebaseAccountTests.swift | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift index f1b00aa..7aa2f22 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift @@ -23,6 +23,7 @@ private struct QueueUpdate { } +// swiftlint:disable:next type_body_length actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") @@ -147,7 +148,12 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { try await Auth.auth().sendPasswordReset(withEmail: userId) Self.logger.debug("sendPasswordReset(withEmail:) for user.") } catch let error as NSError { - throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + let firebaseError = FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + if case .invalidCredentials = firebaseError { + return // make sure we don't leak any information + } else { + throw firebaseError + } } catch { throw FirebaseAccountError.unknown(.internalError) } diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index 517c789..0fc374d 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -291,6 +291,36 @@ final class FirebaseAccountTests: XCTestCase { try app.login(username: "test@username.edu", password: "1234567890", close: false) XCTAssertTrue(app.staticTexts["Username Test"].waitForExistence(timeout: 6.0)) } + + @MainActor + func testPasswordReset() async throws { + let app = XCUIApplication() + app.launchArguments = ["--firebaseAccount"] + app.launch() + + XCTAssert(app.buttons["FirebaseAccount"].waitForExistence(timeout: 10.0)) + app.buttons["FirebaseAccount"].tap() + + if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { + app.buttons["Logout"].tap() + } + + app.buttons["Account Setup"].tap() + + XCTAssertTrue(app.buttons["Forgot Password?"].waitForExistence(timeout: 2.0)) + + app.buttons["Forgot Password?"].tap() + + XCTAssertTrue(app.buttons["Reset Password"].waitForExistence(timeout: 2.0)) + + let fields = app.textFields.matching(identifier: "E-Mail Address").allElementsBoundByIndex + try fields.last?.enter(value: "non-existent@username.edu") + + app.buttons["Reset Password"].tap() + + XCTAssertTrue(app.staticTexts["Sent out a link to reset the password."].waitForExistence(timeout: 2.0)) + app.buttons["Done"].tap() + } @MainActor func testInvalidCredentials() async throws {