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/Package.swift b/Package.swift index ea2efdd..8f47725 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,8 @@ 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", .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/AccountValues/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift new file mode 100644 index 0000000..1a63fa9 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift @@ -0,0 +1,51 @@ +// +// 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 +import SpeziAccount +import SwiftUI + + +/// 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 + } +} + + +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 55be376..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. @@ -34,24 +35,14 @@ import SpeziFirebaseConfiguration /// } /// } /// ``` -public final class FirebaseAccountConfiguration: Component, ObservableObject, ObservableObjectProvider { +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 - private let account: Account - private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? - - @MainActor @Published public var user: User? - - - public var observableObjects: [any ObservableObject] { - [ - self, - account - ] - } - + + @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. @@ -62,54 +53,24 @@ public final class FirebaseAccountConfiguration: Component, ObservableObject, Ob ) { self.emulatorSettings = emulatorSettings self.authenticationMethods = authenticationMethods - - - var accountServices: [any AccountService] = [] + self.accountServices = [] + if authenticationMethods.contains(.emailAndPassword) { - accountServices.append(FirebaseEmailPasswordAccountService()) + self.accountServices.append(FirebaseEmailPasswordAccountService()) } - self.account = Account(accountServices: accountServices) } - 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 - } + // 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 { + await (accountService as? FirebaseEmailPasswordAccountService)?.configure(with: secureStorage) } } } diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift index 628c919..9ec81d9 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift @@ -14,7 +14,11 @@ enum FirebaseAccountError: LocalizedError { case invalidEmail case accountAlreadyInUse case weakPassword + case invalidCredentials + case internalPasswordResetError case setupError + case notSignedIn + case requireRecentLogin case unknown(AuthErrorCode.Code) @@ -26,8 +30,16 @@ 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 .notSignedIn: + return "FIREBASE_ACCOUNT_SIGN_IN_ERROR" + case .requireRecentLogin: + return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR" case .unknown: return "FIREBASE_ACCOUNT_UNKNOWN" } @@ -37,10 +49,6 @@ enum FirebaseAccountError: LocalizedError { .init(localized: errorDescriptionValue, bundle: .module) } - var failureReason: String? { - errorDescription - } - private var recoverySuggestionValue: String.LocalizationValue { switch self { case .invalidEmail: @@ -49,8 +57,16 @@ 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 .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" } @@ -62,15 +78,23 @@ enum FirebaseAccountError: LocalizedError { init(authErrorCode: AuthErrorCode) { + FirebaseEmailPasswordAccountService.logger.debug("Received authError with code \(authErrorCode)") + switch authErrorCode.code { - case .invalidEmail: + case .invalidEmail, .invalidRecipientEmail: self = .invalidEmail case .emailAlreadyInUse: self = .accountAlreadyInUse case .weakPassword: self = .weakPassword + case .userDisabled, .wrongPassword, .userNotFound, .userMismatch: + self = .invalidCredentials + case .invalidSender, .invalidMessagePayload: + 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 7b27f52..7aa2f22 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift @@ -1,101 +1,401 @@ // // 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 // import FirebaseAuth +import OSLog import SpeziAccount +import SpeziSecureStorage import SwiftUI -class FirebaseEmailPasswordAccountService: EmailPasswordAccountService { - static var defaultPasswordValidationRule: ValidationRule { - guard let regex = try? Regex(#"[^\s]{8,}"#) else { - fatalError("Invalid Password Regex in the FirebaseEmailPasswordAccountService") +private enum UserChange { + case user(_ user: User) + case removed +} + +private struct QueueUpdate { + let change: UserChange +} + + +// swiftlint:disable:next type_body_length +actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { + static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") + + private static let supportedKeys = AccountKeyCollection { + \.userId + \.password + \.name + } + + 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: String(localized: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR", bundle: .module) + message: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR \(6)", + bundle: .module ) } - - - private var passwordValidationRule: ValidationRule - - - override var loginButton: AnyView { - button( - localization.login.buttonTitle, - destination: UsernamePasswordLoginView( - usernameValidationRules: [emailValidationRule], - passwordValidationRules: [passwordValidationRule] - ) - ) + + @AccountReference private var account: Account + private var secureStorage: SecureStorage? + + let configuration: AccountServiceConfiguration + private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? + + private var shouldQueue = false + private var queuedUpdate: QueueUpdate? + + init(passwordValidationRules: [ValidationRule] = [minimumFirebasePassword]) { + self.configuration = AccountServiceConfiguration( + name: LocalizedStringResource("FIREBASE_EMAIL_AND_PASSWORD", bundle: .atURL(from: .module)), + 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) + } } - - override var signUpButton: AnyView { - button( - localization.signUp.buttonTitle, - destination: UsernamePasswordSignUpView( - signUpOptions: [.usernameAndPassword, .name], - usernameValidationRules: [emailValidationRule], - passwordValidationRules: [passwordValidationRule] - ) - ) + + + func configure(with secureStorage: SecureStorage) { + 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() + } + } + } } - - - init(passwordValidationRule: ValidationRule = FirebaseEmailPasswordAccountService.defaultPasswordValidationRule) { - self.passwordValidationRule = passwordValidationRule - super.init() + + 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 dispatchQueuedChanges() + + persistCurrentCredentials(userId: userId, password: password) + } catch let error as NSError { + throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + } catch { + throw FirebaseAccountError.unknown(.internalError) + } } - - - override func login(username: String, password: String) async throws { + + func signUp(signupDetails: SignupDetails) async throws { + Self.logger.debug("Received new signup request...") + + defer { + cleanupQueuedChanges() + } + + shouldQueue = true + do { - try await Auth.auth().signIn(withEmail: username, password: password) - - Task { @MainActor in - account?.objectWillChange.send() + let authResult = try await Auth.auth().createUser(withEmail: signupDetails.userId, password: signupDetails.password) + Self.logger.debug("createUser(withEmail:password:) for user.") + + 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.formatted(.name(style: .medium)) + try await changeRequest.commitChanges() } + + try await dispatchQueuedChanges() + + persistCurrentCredentials(userId: signupDetails.userId, password: signupDetails.password) } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { throw FirebaseAccountError.unknown(.internalError) } } - - - override func signUp(signUpValues: SignUpValues) async throws { + + func resetPassword(userId: String) async throws { do { - let authResult = try await Auth.auth().createUser(withEmail: signUpValues.username, password: signUpValues.password) - - let profileChangeRequest = authResult.user.createProfileChangeRequest() - profileChangeRequest.displayName = signUpValues.name.formatted(.name(style: .long)) - try await profileChangeRequest.commitChanges() - Task { @MainActor in - account?.objectWillChange.send() + try await Auth.auth().sendPasswordReset(withEmail: userId) + Self.logger.debug("sendPasswordReset(withEmail:) for user.") + } catch let error as NSError { + let firebaseError = FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + if case .invalidCredentials = firebaseError { + return // make sure we don't leak any information + } else { + throw firebaseError } - - try await authResult.user.sendEmailVerification() + } catch { + throw FirebaseAccountError.unknown(.internalError) + } + } + + func logout() async throws { + guard Auth.auth().currentUser != nil else { + if await account.signedIn { + await notifyUserRemoval() + return + } else { + throw FirebaseAccountError.notSignedIn + } + } + + defer { + cleanupQueuedChanges() + } + + shouldQueue = true + + do { + try Auth.auth().signOut() + Self.logger.debug("signOut() for user.") + + try await dispatchQueuedChanges() + } 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 { + if await account.signedIn { + await notifyUserRemoval() + } + throw FirebaseAccountError.notSignedIn + } + + defer { + cleanupQueuedChanges() + } + + shouldQueue = true + + do { + try await currentUser.delete() + Self.logger.debug("delete() for user.") + + try await dispatchQueuedChanges() } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { throw FirebaseAccountError.unknown(.internalError) } } - - override func resetPassword(username: String) async throws { + + func updateAccountDetails(_ modifications: AccountModifications) async throws { + guard let currentUser = Auth.auth().currentUser else { + if await account.signedIn { + await notifyUserRemoval() + } + throw FirebaseAccountError.notSignedIn + } + + 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 { - try await Auth.auth().sendPasswordReset(withEmail: username) + 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) + + if let userId = currentUser.email { // make sure we save the new password + persistCurrentCredentials(userId: userId, password: password) + } + } + + 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 { + // 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 { throw FirebaseAccountError.unknown(.internalError) } } + + private func stateDidChangeListener(auth: Auth, user: User?) { + // this is called by the FIRAuth framework. + + let change: UserChange + if let user { + change = .user(user) + } else { + change = .removed + } + + + if shouldQueue { + Self.logger.debug("Received stateDidChange that is queued to be dispatched in active call.") + self.queuedUpdate = QueueUpdate(change: change) + } else { + 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) + } + + private func dispatchQueuedChanges() async throws { + shouldQueue = false + + guard let queuedUpdate else { + return + } + + 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)") + } + } + } + + private func apply(change: UserChange) async throws { + switch change { + 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, 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) + } + + 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.") + 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/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings b/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings index f24e846..849686c 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_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 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,20 @@ "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_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" = "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" = "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 630fd9c..6bd38e9 100644 --- a/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings +++ b/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings @@ -6,9 +6,10 @@ // 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."; +"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."; @@ -19,8 +20,20 @@ "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_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 a706be7..f5f99ea 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -15,54 +15,61 @@ 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 + + @State var showSetup = false + @State var showOverview = false + @State var isEditing = false 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 ?? .init(givenName: "NOT FOUND")) + .frame(height: 30) + Text(details.userId) + } + + AsyncButton("Logout", role: .destructive, state: $viewState) { + try await details.accountService.logout() } } - Button("Login") { - showLogin.toggle() - } - .disabled(account.signedIn) - Button("Sign Up") { - showSignUp.toggle() + Button("Account Setup") { + showSetup = true } - .disabled(account.signedIn) - Button("Logout", role: .destructive) { - try? Auth.auth().signOut() + Button("Account Overview") { + showOverview = true } - .disabled(!account.signedIn) } - .sheet(isPresented: $showLogin) { + .sheet(isPresented: $showSetup) { NavigationStack { - Login() + AccountSetup() + .toolbar { + toolbar(closing: $showSetup) + } } } - .sheet(isPresented: $showSignUp) { + .sheet(isPresented: $showOverview) { NavigationStack { - SignUp() + AccountOverview(isEditing: $isEditing) + .toolbar { + toolbar(closing: $showOverview, isEditing: $isEditing) + } } } - .onChange(of: account.signedIn) { signedIn in - if signedIn { - showLogin = false - showSignUp = false + } + + + @ToolbarContentBuilder + 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/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/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() diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index 5fb3362..0fc374d 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -15,56 +15,16 @@ 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) - } - } - - @MainActor override func setUp() async throws { try await super.setUp() try disablePasswordAutofill() - - try await deleteAllAccounts() + + try await FirebaseClient.deleteAllAccounts() try await Task.sleep(for: .seconds(0.5)) } - + @MainActor func testAccountSignUp() async throws { @@ -75,10 +35,10 @@ 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: 10.0) && app.buttons["Logout"].isHittable { + if app.buttons["Logout"].waitForExistence(timeout: 5.0) && app.buttons["Logout"].isHittable { app.buttons["Logout"].tap() } @@ -91,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 }), [ @@ -107,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 }), [ @@ -142,162 +102,315 @@ final class FirebaseAccountTests: XCTestCase { XCTAssert(app.buttons["Logout"].waitForExistence(timeout: 10.0)) app.buttons["Logout"].tap() } - - - // 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") - ) - 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) + + @MainActor + func testAccountLogout() async throws { + try await FirebaseClient.createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Test Username") + + let accounts = try await FirebaseClient.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() } - } - // 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") + 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() // 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 FirebaseClient.getAllAccounts() + XCTAssertEqual( + accounts2.sorted(by: { $0.email < $1.email }), + [ + FirestoreAccount(email: "test@username.edu", displayName: "Test Username") + ] ) - 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) + } + + @MainActor + func testAccountRemoval() async throws { + try await FirebaseClient.createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Test Username") + + let accounts = try await FirebaseClient.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() } - struct ResponseWrapper: Decodable { - let userInfo: [FirestoreAccount] + 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() } - do { - return try JSONDecoder().decode(ResponseWrapper.self, from: data).userInfo - } catch { - return [] + + 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 accountsNew = try await FirebaseClient.getAllAccounts() + XCTAssertEqual(accountsNew, []) + } + + @MainActor + func testAccountEdit() 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: "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 last name"].delete(count: 4) + try app.textFields["enter last name"].enter(value: "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) + try app.textFields["E-Mail Address"].enter(value: "de", checkIfTextWasEnteredCorrectly: false) + + 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")]) + } + + + @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() } + + 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["enter password"].enter(value: "1234567890") + app.dismissKeyboard() + + try app.secureTextFields["re-enter 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=spezifirebaseuitests") - ) - 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 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 { + 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() } } 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)) + 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, close: Bool = true) throws { + buttons["Account Setup"].tap() + XCTAssertTrue(self.buttons["Login"].waitForExistence(timeout: 2.0)) - try textFields["Enter your email ..."].enter(value: username) - dismissKeyboard() + try textFields["E-Mail Address"].enter(value: username) + extendedDismissKeyboard() - try secureTextFields["Enter your password ..."].enter(value: password) - dismissKeyboard() + try secureTextFields["Password"].enter(value: password) + extendedDismissKeyboard() swipeUp() - - let allButtons = scrollViews.buttons.allElementsBoundByIndex - for index in 0.. = try decoder.container( + keyedBy: FirestoreAccount.CodingKeys.self + ) + 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 + } + + self.providerIds = try container + .decode( + [ProviderUserInfo].self, + forKey: 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 21333ef..8d0885e 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 */, ); @@ -566,7 +570,7 @@ repositoryURL = "https://github.com/StanfordSpezi/SpeziAccount.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.4.0; + minimumVersion = 0.5.0; }; }; 2F2E8EFD29E7369B00D439B7 /* XCRemoteSwiftPackageReference "SpeziViews" */ = { @@ -574,7 +578,7 @@ repositoryURL = "https://github.com/StanfordSpezi/SpeziViews.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.3.0; + minimumVersion = 0.5.0; }; }; 2F746D9D29962B2A00BF54FE /* XCRemoteSwiftPackageReference "XCTestExtensions" */ = { @@ -582,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 792ecc9..78c278b 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" : "8a8ec57a272e0d31480fb0893dda0cf4f769b57e", + "version" : "10.15.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,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/Spezi", "state" : { - "revision" : "c98b2e550d83050b76693765884405737c302a77", - "version" : "0.7.0" + "revision" : "7462510badaa156c1e25efd7eabbf5b85ecb0098", + "version" : "0.7.2" } }, { @@ -104,8 +113,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", "state" : { - "revision" : "5828932267c9f371bd0d821d85f915f07481739a", - "version" : "0.4.0" + "revision" : "040aec4d6cf60c2a0f566dbef6f357dc74847507", + "version" : "0.5.0" + } + }, + { + "identity" : "spezistorage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/StanfordSpezi/SpeziStorage", + "state" : { + "revision" : "739ee1eadc5f9b1b5d2e8752ba077243d42fa79f", + "version" : "0.4.2" } }, { @@ -113,8 +131,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziViews.git", "state" : { - "revision" : "c6975e84c735b8b8a13740012c1c194f48893fa8", - "version" : "0.3.0" + "revision" : "626914fa7554aa2b994257541c0794eb930520ba", + "version" : "0.5.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" } }, { @@ -122,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "ce20dc083ee485524b802669890291c0d8090170", - "version" : "1.22.1" + "revision" : "cf62cdaea48b77f1a631e5cb3aeda6047c2cba1d", + "version" : "1.23.0" } }, { @@ -131,8 +158,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/XCTestExtensions", "state" : { - "revision" : "625477e0937294cb3fd6e7bbf72b78f951644b1d", - "version" : "0.4.6" + "revision" : "388a6d6a5be48eff5d98a2c45e0b50f30ed21dc3", + "version" : "0.4.7" } }, { diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index f6731e0..5ba8ba7 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"> + +