[PM-1528] Email verification feature (#813)
@ -51,9 +51,28 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
)
|
||||
hideSplash()
|
||||
isStartingUp = false
|
||||
|
||||
if let userActivity = connectionOptions.userActivities.first,
|
||||
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
|
||||
let incomingURL = userActivity.webpageURL {
|
||||
appProcessor.handleAppLinks(incomingURL: incomingURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
continue userActivity: NSUserActivity
|
||||
) {
|
||||
guard let appProcessor = (UIApplication.shared.delegate as? AppDelegateType)?.appProcessor,
|
||||
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
|
||||
let incomingURL = userActivity.webpageURL else {
|
||||
return
|
||||
}
|
||||
|
||||
appProcessor.handleAppLinks(incomingURL: incomingURL)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts urlContexts: Set<UIOpenURLContext>) {
|
||||
guard let url = urlContexts.first?.url,
|
||||
let appProcessor = (UIApplication.shared.delegate as? AppDelegateType)?.appProcessor
|
||||
|
||||
@ -92,4 +92,25 @@ class SceneDelegateTests: BitwardenTestCase {
|
||||
subject.sceneWillResignActive(scene)
|
||||
XCTAssertEqual(subject.splashWindow?.alpha, 1)
|
||||
}
|
||||
|
||||
/// `scene(_:continue)` runs successfully
|
||||
func test_sceneContinue() {
|
||||
let appProcessor = AppProcessor(
|
||||
appModule: appModule,
|
||||
services: ServiceContainer(errorReporter: MockErrorReporter())
|
||||
)
|
||||
(UIApplication.shared.delegate as? TestingAppDelegate)?.appProcessor = appProcessor
|
||||
|
||||
let session = TestInstanceFactory.create(UISceneSession.self)
|
||||
let userActivity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb)
|
||||
userActivity.webpageURL = URL(string: "https://example.com")
|
||||
let scene = TestInstanceFactory.create(UIWindowScene.self, properties: [
|
||||
"session": session,
|
||||
])
|
||||
let options = TestInstanceFactory.create(UIScene.ConnectionOptions.self)
|
||||
|
||||
subject.scene(scene, willConnectTo: session, options: options)
|
||||
subject.scene(scene, continue: userActivity)
|
||||
XCTAssertEqual(subject.splashWindow?.alpha, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
<string>webcredentials:*.bitwarden.com</string>
|
||||
<string>webcredentials:*.bitwarden.eu</string>
|
||||
<string>webcredentials:*.bitwarden.pw</string>
|
||||
<string>applinks:*.bitwarden.com</string>
|
||||
<string>applinks:*.bitwarden.eu</string>
|
||||
<string>applinks:*.bitwarden.pw</string>
|
||||
</array>
|
||||
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
|
||||
<true/>
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import Networking
|
||||
|
||||
// MARK: - RegisterFinishRequestModel
|
||||
|
||||
/// The data to include in the body of a `RegisterFinishRequest`.
|
||||
///
|
||||
struct RegisterFinishRequestModel: Equatable {
|
||||
// MARK: Properties
|
||||
|
||||
/// The captcha response used in validating a user for this request.
|
||||
let captchaResponse: String?
|
||||
|
||||
/// The user's email address.
|
||||
let email: String
|
||||
|
||||
/// The token to verify the email address
|
||||
let emailVerificationToken: String
|
||||
|
||||
/// The type of kdf for this request.
|
||||
var kdf: KdfType?
|
||||
|
||||
/// The number of kdf iterations performed in this request.
|
||||
var kdfIterations: Int?
|
||||
|
||||
/// The kdf memory allocated for the computed password hash.
|
||||
var kdfMemory: Int?
|
||||
|
||||
/// The number of threads upon which the kdf iterations are performed.
|
||||
var kdfParallelism: Int?
|
||||
|
||||
/// The master password hash used to authenticate a user.
|
||||
let masterPasswordHash: String
|
||||
|
||||
/// The master password hint.
|
||||
let masterPasswordHint: String?
|
||||
|
||||
/// The user's name.
|
||||
let name: String?
|
||||
|
||||
/// The organization's user ID.
|
||||
let organizationUserId: String?
|
||||
|
||||
/// The token used when making this request.
|
||||
let token: String?
|
||||
|
||||
/// The user symmetric key used for this request.
|
||||
let userSymmetricKey: String?
|
||||
|
||||
/// The user asymmetric keys used for this request.
|
||||
let userAsymmetricKeys: KeysRequestModel?
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initializes a `RegisterFinishRequestModel`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - captchaResponse: The captcha response used in validating a user for this request.
|
||||
/// - email: The user's email address.
|
||||
/// - kdfConfig: A model for configuring KDF options.
|
||||
/// - masterPasswordHash: The master password hash used to authenticate a user.
|
||||
/// - masterPasswordHint: The master password hint.
|
||||
/// - name: The user's name.
|
||||
/// - organizationUserId: The organization's user ID.
|
||||
/// - token: The token used when making this request.
|
||||
/// - userSymmetricKey: The key used for this request.
|
||||
/// - userAsymmetricKeys: The keys used for this request.
|
||||
///
|
||||
init(
|
||||
captchaResponse: String? = nil,
|
||||
email: String,
|
||||
emailVerificationToken: String,
|
||||
kdfConfig: KdfConfig,
|
||||
masterPasswordHash: String,
|
||||
masterPasswordHint: String?,
|
||||
name: String? = nil,
|
||||
organizationUserId: String? = nil,
|
||||
token: String? = nil,
|
||||
userSymmetricKey: String? = nil,
|
||||
userAsymmetricKeys: KeysRequestModel? = nil
|
||||
) {
|
||||
self.captchaResponse = captchaResponse
|
||||
self.email = email
|
||||
self.emailVerificationToken = emailVerificationToken
|
||||
kdf = kdfConfig.kdf
|
||||
kdfIterations = kdfConfig.kdfIterations
|
||||
kdfMemory = kdfConfig.kdfMemory
|
||||
kdfParallelism = kdfConfig.kdfParallelism
|
||||
kdfMemory = kdfConfig.kdfMemory
|
||||
self.masterPasswordHash = masterPasswordHash
|
||||
self.masterPasswordHint = masterPasswordHint
|
||||
self.name = name
|
||||
self.organizationUserId = organizationUserId
|
||||
self.token = token
|
||||
self.userSymmetricKey = userSymmetricKey
|
||||
self.userAsymmetricKeys = userAsymmetricKeys
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: JSONRequestBody
|
||||
|
||||
extension RegisterFinishRequestModel: JSONRequestBody {
|
||||
static let encoder = JSONEncoder()
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import Networking
|
||||
|
||||
// MARK: - StartRegistrationRequestModel
|
||||
|
||||
/// The data to include in the body of a `StartRegistrationRequest`.
|
||||
///
|
||||
struct StartRegistrationRequestModel: Equatable {
|
||||
// MARK: Properties
|
||||
|
||||
/// The user's email address.
|
||||
let email: String
|
||||
|
||||
/// The user name.
|
||||
let name: String
|
||||
|
||||
/// If the user wants to receive marketing emails.
|
||||
let receiveMarketingEmails: Bool
|
||||
}
|
||||
|
||||
// MARK: JSONRequestBody
|
||||
|
||||
extension StartRegistrationRequestModel: JSONRequestBody {
|
||||
static let encoder = JSONEncoder()
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
import Networking
|
||||
|
||||
// MARK: - RegisterFinishResponseModel
|
||||
|
||||
/// The response returned from the API upon creating an account.
|
||||
///
|
||||
struct RegisterFinishResponseModel: JSONResponse {
|
||||
static var decoder = JSONDecoder()
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The captcha bypass token returned in this response.
|
||||
var captchaBypassToken: String?
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
// MARK: - RegisterFinishResponseModelTests
|
||||
|
||||
class RegisterFinishResponseModelTests: BitwardenTestCase {
|
||||
/// Tests that a response is initialized correctly.
|
||||
func test_init() {
|
||||
let subject = RegisterFinishResponseModel(captchaBypassToken: "captchaBypassToken")
|
||||
XCTAssertEqual(subject.captchaBypassToken, "captchaBypassToken")
|
||||
}
|
||||
|
||||
/// Tests the successful decoding of a JSON response.
|
||||
func test_decode_success() throws {
|
||||
let json = APITestData.createAccountSuccess.data
|
||||
let decoder = JSONDecoder()
|
||||
let subject = try decoder.decode(RegisterFinishResponseModel.self, from: json)
|
||||
XCTAssertEqual(subject.captchaBypassToken, "captchaBypassToken")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
import Networking
|
||||
|
||||
// MARK: - StartRegistrationResponseModel
|
||||
|
||||
/// The response returned from the API upon sending the verification email.
|
||||
///
|
||||
struct StartRegistrationResponseModel: Response {
|
||||
// MARK: Properties
|
||||
|
||||
/// The email verification token.
|
||||
var token: String?
|
||||
|
||||
init(response: HTTPResponse) {
|
||||
token = String(bytes: response.body, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
@testable import Networking
|
||||
|
||||
// MARK: - StartRegistrationResponseModelTests
|
||||
|
||||
class StartRegistrationResponseModelTests: BitwardenTestCase {
|
||||
/// Tests that a response is initialized correctly.
|
||||
func test_init() {
|
||||
let subject = StartRegistrationResponseModel(
|
||||
response: HTTPResponse(
|
||||
url: URL(string: "https://example.com")!,
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
body: "0018A45C4D1DEF81644B54AB7F969B88D65".data(using: .utf8)!,
|
||||
requestID: UUID()
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(subject.token, "0018A45C4D1DEF81644B54AB7F969B88D65")
|
||||
}
|
||||
}
|
||||
@ -42,6 +42,13 @@ protocol AccountAPIService {
|
||||
///
|
||||
func preLogin(email: String) async throws -> PreLoginResponseModel
|
||||
|
||||
/// Creates an API call for when the user submits the last step of an account creation form.
|
||||
///
|
||||
/// - Parameter body: The body to be included in the request.
|
||||
/// - Returns: Data returned from the `RegisterFinishRequest`.
|
||||
///
|
||||
func registerFinish(body: RegisterFinishRequestModel) async throws -> RegisterFinishResponseModel
|
||||
|
||||
/// Requests a one-time password to be sent to the user.
|
||||
///
|
||||
func requestOtp() async throws
|
||||
@ -54,6 +61,12 @@ protocol AccountAPIService {
|
||||
///
|
||||
func requestPasswordHint(for email: String) async throws
|
||||
|
||||
/// Start user account creation
|
||||
/// - Parameter requestModel: The request model containing the details needed to start user account creation
|
||||
/// - Returns: Can return an email verification token
|
||||
///
|
||||
func startRegistration(requestModel: StartRegistrationRequestModel) async throws -> StartRegistrationResponseModel
|
||||
|
||||
/// Set the account keys.
|
||||
///
|
||||
/// - Parameter requestModel: The request model containing the keys to set in the account.
|
||||
@ -128,6 +141,10 @@ extension APIService: AccountAPIService {
|
||||
return response
|
||||
}
|
||||
|
||||
func registerFinish(body: RegisterFinishRequestModel) async throws -> RegisterFinishResponseModel {
|
||||
try await identityService.send(RegisterFinishRequest(body: body))
|
||||
}
|
||||
|
||||
func requestOtp() async throws {
|
||||
_ = try await apiService.send(RequestOtpRequest())
|
||||
}
|
||||
@ -145,6 +162,10 @@ extension APIService: AccountAPIService {
|
||||
_ = try await apiService.send(SetPasswordRequest(requestModel: requestModel))
|
||||
}
|
||||
|
||||
func startRegistration(requestModel: StartRegistrationRequestModel) async throws -> StartRegistrationResponseModel {
|
||||
try await identityService.send(StartRegistrationRequest(body: requestModel))
|
||||
}
|
||||
|
||||
func updatePassword(_ requestModel: UpdatePasswordRequestModel) async throws {
|
||||
_ = try await apiService.send(UpdatePasswordRequest(requestModel: requestModel))
|
||||
}
|
||||
|
||||
@ -169,6 +169,69 @@ class AccountAPIServiceTests: BitwardenTestCase {
|
||||
XCTAssertNil(response.kdfParallelism)
|
||||
}
|
||||
|
||||
/// `registerFinish(email:masterPasswordHash)` throws an error if the request fails.
|
||||
func test_registerFinish_httpFailure() async {
|
||||
client.result = .httpFailure()
|
||||
|
||||
await assertAsyncThrows {
|
||||
_ = try await subject.registerFinish(
|
||||
body: RegisterFinishRequestModel(
|
||||
email: "example@email.com",
|
||||
emailVerificationToken: "thisisanawesometoken",
|
||||
kdfConfig: KdfConfig(),
|
||||
masterPasswordHash: "1a2b3c",
|
||||
masterPasswordHint: "hint",
|
||||
userSymmetricKey: "key",
|
||||
userAsymmetricKeys: KeysRequestModel(encryptedPrivateKey: "private")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `registerFinish(email:masterPasswordHash)` throws a decoding error if the response is not the expected type.
|
||||
func test_registerFinish_failure() async throws {
|
||||
let resultData = APITestData(data: Data("this should fail".utf8))
|
||||
client.result = .httpSuccess(testData: resultData)
|
||||
|
||||
await assertAsyncThrows {
|
||||
_ = try await subject.registerFinish(
|
||||
body: RegisterFinishRequestModel(
|
||||
email: "example@email.com",
|
||||
emailVerificationToken: "thisisanawesometoken",
|
||||
kdfConfig: KdfConfig(),
|
||||
masterPasswordHash: "1a2b3c",
|
||||
masterPasswordHint: "hint",
|
||||
userSymmetricKey: "key",
|
||||
userAsymmetricKeys: KeysRequestModel(encryptedPrivateKey: "private")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `registerFinish(email:masterPasswordHash)` returns the correct value from the API with a successful request.
|
||||
func test_registerFinish_success() async throws {
|
||||
let resultData = APITestData.createAccountSuccess
|
||||
client.result = .httpSuccess(testData: resultData)
|
||||
|
||||
let successfulResponse = try await subject.registerFinish(
|
||||
body: RegisterFinishRequestModel(
|
||||
email: "example@email.com",
|
||||
emailVerificationToken: "thisisanawesometoken",
|
||||
kdfConfig: KdfConfig(),
|
||||
masterPasswordHash: "1a2b3c",
|
||||
masterPasswordHint: "hint",
|
||||
userSymmetricKey: "key",
|
||||
userAsymmetricKeys: KeysRequestModel(encryptedPrivateKey: "private")
|
||||
)
|
||||
)
|
||||
|
||||
let request = try XCTUnwrap(client.requests.first)
|
||||
XCTAssertEqual(request.method, .post)
|
||||
XCTAssertEqual(request.url.relativePath, "/identity/accounts/register/finish")
|
||||
XCTAssertEqual(successfulResponse.captchaBypassToken, "captchaBypassToken")
|
||||
XCTAssertNotNil(request.body)
|
||||
}
|
||||
|
||||
/// `requestOtp()` performs a request to request a one-time password for the user.
|
||||
func test_requestOtp() async throws {
|
||||
client.result = .httpSuccess(testData: .emptyResponse)
|
||||
@ -181,6 +244,27 @@ class AccountAPIServiceTests: BitwardenTestCase {
|
||||
XCTAssertEqual(client.requests[0].url.absoluteString, "https://example.com/api/accounts/request-otp")
|
||||
}
|
||||
|
||||
/// `startRegistration(_:)` performs the request to start the registration process.
|
||||
func test_startRegistration() async throws {
|
||||
client.result = .httpSuccess(testData: .startRegistrationSuccess)
|
||||
|
||||
let requestModel = StartRegistrationRequestModel(
|
||||
email: "email@example.com",
|
||||
name: "name",
|
||||
receiveMarketingEmails: true
|
||||
)
|
||||
|
||||
_ = try await subject.startRegistration(requestModel: requestModel)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertNotNil(client.requests[0].body)
|
||||
XCTAssertEqual(client.requests[0].method, .post)
|
||||
XCTAssertEqual(
|
||||
client.requests[0].url.absoluteString,
|
||||
"https://example.com/identity/accounts/register/send-verification-email"
|
||||
)
|
||||
}
|
||||
|
||||
/// `setPassword(_:)` performs the request to set the user's password.
|
||||
func test_setPassword() async throws {
|
||||
client.result = .httpSuccess(testData: .emptyResponse)
|
||||
|
||||
@ -30,4 +30,14 @@ extension APITestData {
|
||||
// MARK: Request Password Hint
|
||||
|
||||
static let passwordHintFailure = loadFromJsonBundle(resource: "PasswordHintFailure")
|
||||
|
||||
// MARK: Start Registration
|
||||
|
||||
static let startRegistrationEmailAlreadyExists = loadFromJsonBundle(resource: "StartRegistrationEmailAlreadyExists")
|
||||
static let startRegistrationEmailExceedsMaxLength = loadFromJsonBundle(
|
||||
resource: "StartRegistrationEmailExceedsMaxLength"
|
||||
)
|
||||
static let startRegistrationInvalidEmailFormat = loadFromJsonBundle(resource: "StartRegistrationInvalidEmailFormat")
|
||||
static let startRegistrationCaptchaFailure = loadFromJsonBundle(resource: "StartRegistrationCaptchaFailure")
|
||||
static let startRegistrationSuccess = loadFromBundle(resource: "StartRegistrationSuccess", extension: "txt")
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"message":"The model state is invalid.",
|
||||
"validationErrors":{
|
||||
"HCaptcha_SiteKey":["bc38c8a2-5311-4e8c-9dfc-49e99f6df417"]
|
||||
},
|
||||
"exceptionMessage":null,
|
||||
"exceptionStackTrace":null,
|
||||
"innerExceptionMessage":null,
|
||||
"object":"error"
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"message":"The model state is invalid.",
|
||||
"validationErrors":{
|
||||
"":["Email 'j@a.com' is already taken."]
|
||||
|
||||
},
|
||||
"exceptionMessage":null,
|
||||
"exceptionStackTrace":null,
|
||||
"innerExceptionMessage":null,
|
||||
"object":"error"
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"message":"The model state is invalid.",
|
||||
"validationErrors":{
|
||||
"Email":["The field Email must be a string with a maximum length of 256."]
|
||||
},
|
||||
"exceptionMessage":null,
|
||||
"exceptionStackTrace":null,
|
||||
"innerExceptionMessage":null,
|
||||
"object":"error"
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"message":"The model state is invalid.",
|
||||
"validationErrors":{
|
||||
"Email":["The Email field is not a supported e-mail address format."]
|
||||
},
|
||||
"exceptionMessage":null,
|
||||
"exceptionStackTrace":null,
|
||||
"innerExceptionMessage":null,
|
||||
"object":"error"
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "name",
|
||||
"email": "example@email.com",
|
||||
"captchaResponse": "captchaResponse"
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
0018A45C4D1DEF81644B54AB7F969B88D65
|
||||
@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
import Networking
|
||||
|
||||
// MARK: - RegisterFinishRequestError
|
||||
|
||||
/// Errors that can occur when sending a `RegisterFinishRequest`.
|
||||
enum RegisterFinishRequestError: Error, Equatable {
|
||||
/// Captcha is required when creating an account.
|
||||
///
|
||||
/// - Parameter hCaptchaSiteCode: The site code to use when authenticating with hCaptcha.
|
||||
case captchaRequired(hCaptchaSiteCode: String)
|
||||
}
|
||||
|
||||
// MARK: - RegisterFinishRequest
|
||||
|
||||
/// The API request sent when submitting an account creation form.
|
||||
///
|
||||
struct RegisterFinishRequest: Request {
|
||||
typealias Response = RegisterFinishResponseModel
|
||||
typealias Body = RegisterFinishRequestModel
|
||||
|
||||
/// The body of this request.
|
||||
var body: RegisterFinishRequestModel?
|
||||
|
||||
/// The HTTP method for this request.
|
||||
let method: HTTPMethod = .post
|
||||
|
||||
/// The URL path for this request.
|
||||
var path: String = "/accounts/register/finish"
|
||||
|
||||
/// Creates a new `RegisterFinishRequest` instance.
|
||||
///
|
||||
/// - Parameter body: The body of the request.
|
||||
///
|
||||
init(body: RegisterFinishRequestModel) {
|
||||
self.body = body
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func validate(_ response: HTTPResponse) throws {
|
||||
switch response.statusCode {
|
||||
case 400 ..< 500:
|
||||
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
|
||||
|
||||
if let siteCode = errorResponse.validationErrors?["HCaptcha_SiteKey"]?.first {
|
||||
throw RegisterFinishRequestError.captchaRequired(hCaptchaSiteCode: siteCode)
|
||||
}
|
||||
|
||||
throw ServerError.error(errorResponse: errorResponse)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
import Networking
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
// MARK: - RegisterFinishRequestTests
|
||||
|
||||
class RegisterFinishRequestTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var subject: RegisterFinishRequest!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
subject = RegisterFinishRequest(
|
||||
body: RegisterFinishRequestModel(
|
||||
email: "example@email.com",
|
||||
emailVerificationToken: "thisisanawesometoken",
|
||||
kdfConfig: KdfConfig(),
|
||||
masterPasswordHash: "1a2b3c",
|
||||
masterPasswordHint: "hint",
|
||||
userSymmetricKey: "key",
|
||||
userAsymmetricKeys: KeysRequestModel(encryptedPrivateKey: "private")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// Validate that the method is correct.
|
||||
func test_method() {
|
||||
XCTAssertEqual(subject.method, .post)
|
||||
}
|
||||
|
||||
/// Validate that the path is correct.
|
||||
func test_path() {
|
||||
XCTAssertEqual(subject.path, "/accounts/register/finish")
|
||||
}
|
||||
|
||||
/// Validate that the body is not nil.
|
||||
func test_body() {
|
||||
XCTAssertNotNil(subject.body)
|
||||
}
|
||||
|
||||
/// `validate(_:)` with a `400` status code and an account already exists error in the response body
|
||||
/// throws an `.accountAlreadyExists` error.
|
||||
func test_validate_with400AccountAlreadyExists() throws {
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: APITestData.createAccountAccountAlreadyExists.data
|
||||
)
|
||||
|
||||
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
|
||||
|
||||
XCTAssertThrowsError(try subject.validate(response)) { error in
|
||||
XCTAssertEqual(error as? ServerError, .error(errorResponse: errorResponse))
|
||||
}
|
||||
}
|
||||
|
||||
/// `validate(_:)` with a `400` status code and captcha error in the response body throws a `.captchaRequired`
|
||||
/// error.
|
||||
func test_validate_with400CaptchaError() {
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: APITestData.createAccountCaptchaFailure.data
|
||||
)
|
||||
|
||||
XCTAssertThrowsError(try subject.validate(response)) { error in
|
||||
XCTAssertEqual(
|
||||
error as? RegisterFinishRequestError,
|
||||
.captchaRequired(hCaptchaSiteCode: "bc38c8a2-5311-4e8c-9dfc-49e99f6df417")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `validate(_:)` with a `400` status code and an invalid email format error in the response body
|
||||
/// throws an `.invalidEmailFormat` error.
|
||||
func test_validate_with400InvalidEmailFormat() {
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: APITestData.createAccountInvalidEmailFormat.data
|
||||
)
|
||||
|
||||
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
|
||||
|
||||
XCTAssertThrowsError(try subject.validate(response)) { error in
|
||||
XCTAssertEqual(error as? ServerError, .error(errorResponse: errorResponse))
|
||||
}
|
||||
}
|
||||
|
||||
/// `validate(_:)` with a `400` status code but no captcha error does not throw a validation error.
|
||||
func test_validate_with400NonCaptchaError() {
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: Data("example data".utf8)
|
||||
)
|
||||
|
||||
XCTAssertNoThrow(try subject.validate(response))
|
||||
}
|
||||
|
||||
/// `validate(_:)` with a valid response does not throw a validation error.
|
||||
func test_validate_with200() {
|
||||
let response = HTTPResponse.success(
|
||||
body: APITestData.createAccountSuccess.data
|
||||
)
|
||||
|
||||
XCTAssertNoThrow(try subject.validate(response))
|
||||
}
|
||||
|
||||
// MARK: Init
|
||||
|
||||
/// Validate that the value provided to the init method is correct.
|
||||
func test_init_body() {
|
||||
XCTAssertEqual(subject.body?.email, "example@email.com")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import Networking
|
||||
|
||||
// MARK: - StartRegistrationRequest
|
||||
|
||||
/// The API request sent when starting the account creation.
|
||||
///
|
||||
struct StartRegistrationRequest: Request {
|
||||
typealias Response = StartRegistrationResponseModel
|
||||
|
||||
typealias Body = StartRegistrationRequestModel
|
||||
|
||||
/// The body of this request.
|
||||
var body: StartRegistrationRequestModel?
|
||||
|
||||
/// The HTTP method for this request.
|
||||
let method: HTTPMethod = .post
|
||||
|
||||
/// The URL path for this request.
|
||||
var path: String = "/accounts/register/send-verification-email"
|
||||
|
||||
/// Creates a new `StartRegistrationRequest` instance.
|
||||
///
|
||||
/// - Parameter body: The body of the request.
|
||||
///
|
||||
init(body: StartRegistrationRequestModel) {
|
||||
self.body = body
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import Networking
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
// MARK: - StartRegistrationRequestTests
|
||||
|
||||
class StartRegistrationRequestTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var subject: StartRegistrationRequest!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
subject = StartRegistrationRequest(
|
||||
body: StartRegistrationRequestModel(
|
||||
email: "example@email.com",
|
||||
name: "key",
|
||||
receiveMarketingEmails: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// Validate that the method is correct.
|
||||
func test_method() {
|
||||
XCTAssertEqual(subject.method, .post)
|
||||
}
|
||||
|
||||
/// Validate that the path is correct.
|
||||
func test_path() {
|
||||
XCTAssertEqual(subject.path, "/accounts/register/send-verification-email")
|
||||
}
|
||||
|
||||
/// Validate that the body is not nil.
|
||||
func test_body() {
|
||||
XCTAssertNotNil(subject.body)
|
||||
}
|
||||
|
||||
/// `validate(_:)` with a `400` status code but no captcha error does not throw a validation error.
|
||||
func test_validate_with400() {
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: Data("example data".utf8)
|
||||
)
|
||||
|
||||
XCTAssertNoThrow(try subject.validate(response))
|
||||
}
|
||||
|
||||
/// `validate(_:)` with a valid response does not throw a validation error.
|
||||
func test_validate_with200() {
|
||||
let response = HTTPResponse.success(
|
||||
body: APITestData.startRegistrationSuccess.data
|
||||
)
|
||||
|
||||
XCTAssertNoThrow(try subject.validate(response))
|
||||
}
|
||||
|
||||
// MARK: Init
|
||||
|
||||
/// Validate that the value provided to the init method is correct.
|
||||
func test_init_body() {
|
||||
XCTAssertEqual(subject.body?.email, "example@email.com")
|
||||
}
|
||||
}
|
||||
@ -10,4 +10,7 @@ enum FeatureFlag: String, Codable {
|
||||
|
||||
/// A feature flag for showing the unassigned items banner.
|
||||
case unassignedItemsBanner = "unassigned-items-banner"
|
||||
/// Flag to enable/disable email verification during registration
|
||||
/// This flag introduces a new flow for account creation
|
||||
case emailVerification = "email-verification"
|
||||
}
|
||||
|
||||
@ -35,4 +35,7 @@ enum ExternalLinksConstants {
|
||||
|
||||
/// A markdown link to Bitwarden's terms of service.
|
||||
static let termsOfService = URL(string: "https://bitwarden.com/terms/")!
|
||||
|
||||
/// A markdown link to Bitwarden's markting email preferences.
|
||||
static let unsubscribeFromMarketingEmails = URL(string: "https://bitwarden.com/email-preferences/")!
|
||||
}
|
||||
|
||||
@ -3,6 +3,15 @@ import OSLog
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - AuthCoordinatorError
|
||||
|
||||
/// The errors thrown from a `AuthCoordinator`.
|
||||
///
|
||||
enum AuthCoordinatorError: Error {
|
||||
/// When the received delegate does not have a value.
|
||||
case delegateIsNil
|
||||
}
|
||||
|
||||
// MARK: - AuthCoordinatorDelegate
|
||||
|
||||
/// An object that is signaled when specific circumstances in the auth flow have been encountered.
|
||||
@ -35,6 +44,7 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
|
||||
& HasBiometricsRepository
|
||||
& HasCaptchaService
|
||||
& HasClientService
|
||||
& HasConfigService
|
||||
& HasDeviceAPIService
|
||||
& HasEnvironmentService
|
||||
& HasErrorReporter
|
||||
@ -112,6 +122,8 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
|
||||
callbackUrlScheme: callbackUrlScheme,
|
||||
delegate: context as? CaptchaFlowDelegate
|
||||
)
|
||||
case let .checkEmail(email):
|
||||
showCheckEmail(email)
|
||||
case .complete,
|
||||
.completeWithNeverUnlockKey:
|
||||
if stackNavigator?.isPresenting == true {
|
||||
@ -121,10 +133,35 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
|
||||
} else {
|
||||
delegate?.didCompleteAuth()
|
||||
}
|
||||
case let .completeRegistration(emailVerificationToken, userEmail):
|
||||
showCompleteRegistration(
|
||||
emailVerificationToken: emailVerificationToken,
|
||||
userEmail: userEmail,
|
||||
region: nil
|
||||
)
|
||||
case let .completeRegistrationFromAppLink(emailVerificationToken, userEmail, fromEmail, region):
|
||||
// Coming from an AppLink clear the current stack
|
||||
stackNavigator?.dismiss {
|
||||
self.showLanding()
|
||||
self.showCompleteRegistration(
|
||||
emailVerificationToken: emailVerificationToken,
|
||||
userEmail: userEmail,
|
||||
fromEmail: fromEmail,
|
||||
region: region
|
||||
)
|
||||
}
|
||||
case .createAccount:
|
||||
showCreateAccount()
|
||||
case .startRegistration:
|
||||
showStartRegistration(delegate: context as? StartRegistrationDelegate)
|
||||
case .dismiss:
|
||||
stackNavigator?.dismiss()
|
||||
case .dismissPresented:
|
||||
stackNavigator?.rootViewController?.presentedViewController?.dismiss(animated: true)
|
||||
case let .dismissWithAction(onDismiss):
|
||||
stackNavigator?.dismiss(animated: true, completion: {
|
||||
onDismiss?.action()
|
||||
})
|
||||
case let .duoAuthenticationFlow(authURL):
|
||||
showDuo2FA(authURL: authURL, delegate: context as? DuoAuthenticationFlowDelegate)
|
||||
case let .enterpriseSingleSignOn(email):
|
||||
@ -243,6 +280,22 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
|
||||
session.start()
|
||||
}
|
||||
|
||||
/// Shows the check email screen.
|
||||
/// - Parameter email: The user's email.
|
||||
///
|
||||
private func showCheckEmail(_ email: String) {
|
||||
let view = CheckEmailView(
|
||||
store: Store(
|
||||
processor: CheckEmailProcessor(
|
||||
coordinator: asAnyCoordinator(),
|
||||
state: CheckEmailState(email: email)
|
||||
)
|
||||
)
|
||||
)
|
||||
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
|
||||
stackNavigator?.present(navController)
|
||||
}
|
||||
|
||||
/// Shows the create account screen.
|
||||
///
|
||||
private func showCreateAccount() {
|
||||
@ -259,6 +312,32 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
|
||||
stackNavigator?.present(navController)
|
||||
}
|
||||
|
||||
/// Shows the complete registration screen.
|
||||
///
|
||||
private func showCompleteRegistration(
|
||||
emailVerificationToken: String,
|
||||
userEmail: String,
|
||||
fromEmail: Bool = false,
|
||||
region: RegionType?
|
||||
) {
|
||||
let view = CompleteRegistrationView(
|
||||
store: Store(
|
||||
processor: CompleteRegistrationProcessor(
|
||||
coordinator: asAnyCoordinator(),
|
||||
services: services,
|
||||
state: CompleteRegistrationState(
|
||||
emailVerificationToken: emailVerificationToken,
|
||||
fromEmail: fromEmail,
|
||||
region: region,
|
||||
userEmail: userEmail
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
|
||||
stackNavigator?.present(navController)
|
||||
}
|
||||
|
||||
/// Shows the Duo 2FA screen.
|
||||
///
|
||||
/// - Parameters:
|
||||
@ -535,6 +614,29 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
|
||||
session.start()
|
||||
}
|
||||
|
||||
/// Shows the start registration screen.
|
||||
///
|
||||
private func showStartRegistration(delegate: StartRegistrationDelegate?) {
|
||||
guard let delegate else {
|
||||
services.errorReporter.log(error: AuthCoordinatorError.delegateIsNil)
|
||||
return
|
||||
}
|
||||
let processor = StartRegistrationProcessor(
|
||||
coordinator: asAnyCoordinator(),
|
||||
delegate: delegate,
|
||||
services: services,
|
||||
state: StartRegistrationState()
|
||||
)
|
||||
|
||||
let view = StartRegistrationView(
|
||||
store: Store(
|
||||
processor: processor
|
||||
)
|
||||
)
|
||||
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
|
||||
stackNavigator?.present(navController)
|
||||
}
|
||||
|
||||
/// Show the two factor authentication view.
|
||||
///
|
||||
/// - Parameters:
|
||||
|
||||
@ -86,6 +86,15 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b
|
||||
XCTAssertEqual(stackNavigator.actions.last?.type, .dismissedWithCompletionHandler)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.checkEmail` pushes the check email view onto the stack navigator.
|
||||
func test_navigate_checkEmail() throws {
|
||||
subject.navigate(to: .checkEmail(email: "email@example.com"))
|
||||
|
||||
let navigationController = try XCTUnwrap(stackNavigator.actions.last?.view as? UINavigationController)
|
||||
XCTAssertTrue(stackNavigator.actions.last?.view is UINavigationController)
|
||||
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<CheckEmailView>)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.completeWithNeverUnlockKey` notifies the delegate that auth has completed.
|
||||
func test_navigate_completeWithNeverUnlockKey() {
|
||||
subject.navigate(to: .completeWithNeverUnlockKey)
|
||||
@ -101,7 +110,52 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b
|
||||
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<CreateAccountView>)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.dismiss` dismisses the presented view.
|
||||
/// `navigate(to:)` with `.completeRegistration` pushes the create account view onto the stack navigator.
|
||||
func test_navigate_completeRegistration() throws {
|
||||
subject.navigate(to: .completeRegistration(
|
||||
emailVerificationToken: "thisisanamazingtoken",
|
||||
userEmail: "email@example.com"
|
||||
))
|
||||
|
||||
let navigationController = try XCTUnwrap(stackNavigator.actions.last?.view as? UINavigationController)
|
||||
XCTAssertTrue(stackNavigator.actions.last?.view is UINavigationController)
|
||||
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<CompleteRegistrationView>)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.completeRegistrationFromAppLink` pushes the create account view onto the stack navigator.
|
||||
func test_navigate_completeRegistrationFromAppLink() throws {
|
||||
subject.navigate(to: .completeRegistrationFromAppLink(
|
||||
emailVerificationToken: "thisisanamazingtoken",
|
||||
userEmail: "email@example.com",
|
||||
fromEmail: true,
|
||||
region: .unitedStates
|
||||
))
|
||||
|
||||
let landingAction = try XCTUnwrap(stackNavigator.actions[1])
|
||||
let completeRegistrationAction = try XCTUnwrap(stackNavigator.actions[2])
|
||||
let completeRegistrationNavigationController = try XCTUnwrap(
|
||||
completeRegistrationAction.view as? UINavigationController
|
||||
)
|
||||
let lastAction = try XCTUnwrap(stackNavigator.actions.last)
|
||||
|
||||
XCTAssertTrue(
|
||||
completeRegistrationNavigationController.viewControllers.first
|
||||
is UIHostingController<CompleteRegistrationView>
|
||||
)
|
||||
XCTAssertTrue(landingAction.view is LandingView)
|
||||
XCTAssertEqual(lastAction.type, .dismissedWithCompletionHandler)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.startRegistration` pushes the start registration view onto the stack navigator.
|
||||
func test_navigate_startRegistration() throws {
|
||||
subject.navigate(to: .startRegistration, context: MockStartRegistrationDelegate())
|
||||
|
||||
let navigationController = try XCTUnwrap(stackNavigator.actions.last?.view as? UINavigationController)
|
||||
XCTAssertTrue(stackNavigator.actions.last?.view is UINavigationController)
|
||||
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<StartRegistrationView>)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.dismiss` dismisses all presented view.
|
||||
func test_navigate_dismiss() throws {
|
||||
subject.navigate(to: .createAccount)
|
||||
subject.navigate(to: .dismiss)
|
||||
@ -109,6 +163,23 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b
|
||||
XCTAssertEqual(lastAction.type, .dismissed)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.dismissPresented` dismisses the presented view.
|
||||
func test_navigate_dismissPresented() throws {
|
||||
subject.navigate(to: .checkEmail(email: "email@example.com"))
|
||||
subject.navigate(to: .dismissPresented)
|
||||
let lastAction = try XCTUnwrap(stackNavigator.actions.last)
|
||||
XCTAssertEqual(lastAction.type, .presented)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.dismissWithAction` dismisses the presented view.
|
||||
func test_navigate_dismissWithAction() throws {
|
||||
var didRun = false
|
||||
subject.navigate(to: .dismissWithAction(DismissAction(action: { didRun = true })))
|
||||
let lastAction = try XCTUnwrap(stackNavigator.actions.last)
|
||||
XCTAssertEqual(lastAction.type, .dismissedWithCompletionHandler)
|
||||
XCTAssertTrue(didRun)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.enterpriseSingleSignOn` pushes the enterprise single sign-on view onto the stack
|
||||
/// navigator.
|
||||
func test_navigate_enterpriseSingleSignOn() throws {
|
||||
|
||||
@ -7,9 +7,33 @@ public enum AuthRoute: Equatable {
|
||||
/// A route to the captcha screen.
|
||||
case captcha(url: URL, callbackUrlScheme: String)
|
||||
|
||||
/// A route to show the check email screen.
|
||||
case checkEmail(email: String)
|
||||
|
||||
/// Dismisses the auth flow.
|
||||
case complete
|
||||
|
||||
/// A route to complete registration screen.
|
||||
/// - Parameters:
|
||||
/// - emailVerificationToken: Token needed to complete registration.
|
||||
/// - userEmail: The user's email.
|
||||
///
|
||||
case completeRegistration(emailVerificationToken: String, userEmail: String)
|
||||
|
||||
/// A route to complete registration screen.
|
||||
/// - Parameters:
|
||||
/// - emailVerificationToken: Token needed to complete registration.
|
||||
/// - userEmail: The user's email.
|
||||
/// - fromEmail: The user opened the app from an email AppLink.
|
||||
/// - region: Region where the complete registration should happen.
|
||||
///
|
||||
case completeRegistrationFromAppLink(
|
||||
emailVerificationToken: String,
|
||||
userEmail: String,
|
||||
fromEmail: Bool,
|
||||
region: RegionType
|
||||
)
|
||||
|
||||
/// Dismisses the auth flow becuase the vault was unlocked with the never unlock key.
|
||||
case completeWithNeverUnlockKey
|
||||
|
||||
@ -19,6 +43,15 @@ public enum AuthRoute: Equatable {
|
||||
/// A route that dismisses a presented sheet.
|
||||
case dismiss
|
||||
|
||||
/// A route that dismisses only the presented sheet.
|
||||
case dismissPresented
|
||||
|
||||
/// A route to dismiss the screen currently presented modally.
|
||||
///
|
||||
/// - Parameter action: The action to perform on dismiss.
|
||||
///
|
||||
case dismissWithAction(_ action: DismissAction? = nil)
|
||||
|
||||
/// A route that triggers the duo 2FA flow.
|
||||
/// Requires that any `context` provided to the coordinator conforms to `DuoAuthenticationFlowDelegate`.
|
||||
case duoAuthenticationFlow(_ authURL: URL)
|
||||
@ -47,6 +80,10 @@ public enum AuthRoute: Equatable {
|
||||
///
|
||||
case showLoginDecryptionOptions(organizationIdentifier: String)
|
||||
|
||||
/// A route to start registration screen.
|
||||
///
|
||||
case startRegistration
|
||||
|
||||
/// A route to the login with device screen.
|
||||
///
|
||||
/// - Parameters:
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
// MARK: - CompleteRegistrationAction
|
||||
|
||||
/// Actions that can be processed by a `CompleteRegistrationProcessor`.
|
||||
///
|
||||
enum CompleteRegistrationAction: Equatable {
|
||||
/// The `CompleteRegistrationView` was dismissed.
|
||||
case dismiss
|
||||
|
||||
/// The user edited the password hint text field.
|
||||
case passwordHintTextChanged(String)
|
||||
|
||||
/// The user edited the master password text field.
|
||||
case passwordTextChanged(String)
|
||||
|
||||
/// The user edited the re-type password text field.
|
||||
case retypePasswordTextChanged(String)
|
||||
|
||||
/// The toast was shown or hidden.
|
||||
case toastShown(Toast?)
|
||||
|
||||
/// An action to toggle the data breach check.
|
||||
case toggleCheckDataBreaches(Bool)
|
||||
|
||||
/// An action to toggle whether passwords in text fields are visible.
|
||||
case togglePasswordVisibility(Bool)
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
// MARK: - CompleteRegistrationEffect
|
||||
|
||||
/// The enumeration of possible effects performed by the `CompleteRegistrationProcessor`.
|
||||
///
|
||||
enum CompleteRegistrationEffect: Equatable {
|
||||
/// The complete registration modal appeared on screen.
|
||||
case appeared
|
||||
|
||||
/// The user pressed `Submit` on the `CompleteRegistrationView`, attempting to create an account.
|
||||
case completeRegistration
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
import AuthenticationServices
|
||||
import BitwardenSdk
|
||||
import Combine
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
// MARK: - CompleteRegistrationError
|
||||
|
||||
/// Enumeration of errors that may occur when completing registration for an account.
|
||||
///
|
||||
enum CompleteRegistrationError: Error {
|
||||
/// The password confirmation is not correct.
|
||||
case passwordsDontMatch
|
||||
|
||||
/// The password field is empty.
|
||||
case passwordEmpty
|
||||
|
||||
/// The password does not meet the minimum length requirement.
|
||||
case passwordIsTooShort
|
||||
}
|
||||
|
||||
// MARK: - CompleteRegistrationProcessor
|
||||
|
||||
/// The processor used to manage state and handle actions for the completing registration screen.
|
||||
///
|
||||
class CompleteRegistrationProcessor: StateProcessor<
|
||||
CompleteRegistrationState,
|
||||
CompleteRegistrationAction,
|
||||
CompleteRegistrationEffect
|
||||
> {
|
||||
// MARK: Types
|
||||
|
||||
typealias Services = HasAccountAPIService
|
||||
& HasAuthRepository
|
||||
& HasClientService
|
||||
& HasEnvironmentService
|
||||
& HasErrorReporter
|
||||
& HasStateService
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
/// The coordinator that handles navigation.
|
||||
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
|
||||
|
||||
/// The services used by the processor.
|
||||
private let services: Services
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `CompleteRegistrationProcessor`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - coordinator: The coordinator that handles navigation.
|
||||
/// - services: The services used by the processor.
|
||||
/// - state: The initial state of the processor.
|
||||
///
|
||||
init(
|
||||
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
|
||||
services: Services,
|
||||
state: CompleteRegistrationState
|
||||
) {
|
||||
self.coordinator = coordinator
|
||||
self.services = services
|
||||
super.init(state: state)
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
override func perform(_ effect: CompleteRegistrationEffect) async {
|
||||
switch effect {
|
||||
case .appeared:
|
||||
await setRegion()
|
||||
await verifyUserEmail()
|
||||
case .completeRegistration:
|
||||
await checkPasswordAndCompleteRegistration()
|
||||
}
|
||||
}
|
||||
|
||||
override func receive(_ action: CompleteRegistrationAction) {
|
||||
switch action {
|
||||
case .dismiss:
|
||||
coordinator.navigate(to: .dismissPresented)
|
||||
case let .passwordHintTextChanged(text):
|
||||
state.passwordHintText = text
|
||||
case let .passwordTextChanged(text):
|
||||
state.passwordText = text
|
||||
updatePasswordStrength()
|
||||
case let .retypePasswordTextChanged(text):
|
||||
state.retypePasswordText = text
|
||||
case let .toastShown(toast):
|
||||
state.toast = toast
|
||||
case let .toggleCheckDataBreaches(newValue):
|
||||
state.isCheckDataBreachesToggleOn = newValue
|
||||
case let .togglePasswordVisibility(newValue):
|
||||
state.arePasswordsVisible = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private methods
|
||||
|
||||
/// Shows an alert if the user's password has been found in a data breach.
|
||||
/// Also shows an alert if it hasn't, but the password is still weak.
|
||||
///
|
||||
/// - Parameter isWeakPassword: Whether the password is weak.
|
||||
///
|
||||
private func checkForBreaches(isWeakPassword: Bool) async {
|
||||
do {
|
||||
coordinator.showLoadingOverlay(title: Localizations.creatingAccount)
|
||||
let breachCount = try await services.accountAPIService.checkDataBreaches(password: state.passwordText)
|
||||
|
||||
// If unexposed and strong, complete registration
|
||||
guard breachCount > 0 || isWeakPassword else {
|
||||
await completeRegistration()
|
||||
return
|
||||
}
|
||||
|
||||
// If exposed and/or weak, show alert
|
||||
coordinator.hideLoadingOverlay()
|
||||
let alertType = Alert.PasswordStrengthAlertType(isBreached: breachCount > 0, isWeak: isWeakPassword)
|
||||
coordinator.showAlert(.passwordStrengthAlert(alertType) {
|
||||
await self.completeRegistration()
|
||||
})
|
||||
} catch {
|
||||
await completeRegistration()
|
||||
Logger.processor.error("HIBP network request failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the password strength and conditionally checks the password against data breaches.
|
||||
///
|
||||
/// An alert is shown if the password:
|
||||
/// - Is exposed and weak
|
||||
/// - is exposed and strong
|
||||
/// - is unexposed and weak
|
||||
/// - is unchecked against breaches and weak
|
||||
///
|
||||
private func checkPasswordAndCompleteRegistration() async {
|
||||
if state.isCheckDataBreachesToggleOn {
|
||||
await checkForBreaches(isWeakPassword: state.isWeakPassword)
|
||||
} else {
|
||||
guard !state.isWeakPassword else {
|
||||
coordinator.showAlert(.passwordStrengthAlert(.weak) {
|
||||
await self.completeRegistration()
|
||||
})
|
||||
return
|
||||
}
|
||||
await completeRegistration()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the user's account with their provided credentials.
|
||||
///
|
||||
/// - Parameter captchaToken: The token returned when the captcha flow has completed.
|
||||
///
|
||||
private func completeRegistration(captchaToken: String? = nil) async {
|
||||
defer { coordinator.hideLoadingOverlay() }
|
||||
|
||||
do {
|
||||
guard !state.passwordText.isEmpty else { throw CompleteRegistrationError.passwordEmpty }
|
||||
|
||||
guard state.passwordText.count >= Constants.minimumPasswordCharacters else {
|
||||
throw CompleteRegistrationError.passwordIsTooShort
|
||||
}
|
||||
|
||||
guard state.passwordText == state.retypePasswordText else {
|
||||
throw CompleteRegistrationError.passwordsDontMatch
|
||||
}
|
||||
|
||||
coordinator.showLoadingOverlay(title: Localizations.creatingAccount)
|
||||
|
||||
let kdf: Kdf = .pbkdf2(iterations: NonZeroU32(KdfConfig().kdfIterations))
|
||||
|
||||
let keys = try await services.clientService.auth().makeRegisterKeys(
|
||||
email: state.userEmail,
|
||||
password: state.passwordText,
|
||||
kdf: kdf
|
||||
)
|
||||
|
||||
let hashedPassword = try await services.clientService.auth().hashPassword(
|
||||
email: state.userEmail,
|
||||
password: state.passwordText,
|
||||
kdfParams: kdf,
|
||||
purpose: .serverAuthorization
|
||||
)
|
||||
|
||||
_ = try await services.accountAPIService.registerFinish(
|
||||
body: RegisterFinishRequestModel(
|
||||
captchaResponse: captchaToken,
|
||||
email: state.userEmail,
|
||||
emailVerificationToken: state.emailVerificationToken,
|
||||
kdfConfig: KdfConfig(),
|
||||
masterPasswordHash: hashedPassword,
|
||||
masterPasswordHint: state.passwordHintText,
|
||||
userSymmetricKey: keys.encryptedUserKey,
|
||||
userAsymmetricKeys: KeysRequestModel(
|
||||
encryptedPrivateKey: keys.keys.private,
|
||||
publicKey: keys.keys.public
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
coordinator.navigate(to: .dismissWithAction(DismissAction {
|
||||
self.coordinator.showToast(Localizations.accountSuccessfullyCreated)
|
||||
}))
|
||||
} catch let error as CompleteRegistrationError {
|
||||
showCompleteRegistrationErrorAlert(error)
|
||||
} catch {
|
||||
coordinator.showAlert(.networkResponseError(error) {
|
||||
await self.completeRegistration(captchaToken: captchaToken)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the URLs to use.
|
||||
///
|
||||
private func setRegion() async {
|
||||
guard state.region != nil,
|
||||
let urls = state.region?.defaultURLs else { return }
|
||||
|
||||
await services.environmentService.setPreAuthURLs(urls: urls)
|
||||
}
|
||||
|
||||
/// Shows a `CompleteRegistrationError` alert.
|
||||
///
|
||||
/// - Parameter error: The error that occurred.
|
||||
///
|
||||
private func showCompleteRegistrationErrorAlert(_ error: CompleteRegistrationError) {
|
||||
switch error {
|
||||
case .passwordsDontMatch:
|
||||
coordinator.showAlert(.passwordsDontMatch)
|
||||
case .passwordEmpty:
|
||||
coordinator.showAlert(.validationFieldRequired(fieldName: Localizations.masterPassword))
|
||||
case .passwordIsTooShort:
|
||||
coordinator.showAlert(.passwordIsTooShort)
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates state's password strength score based on the user's entered password.
|
||||
///
|
||||
private func updatePasswordStrength() {
|
||||
guard !state.passwordText.isEmpty else {
|
||||
state.passwordStrengthScore = nil
|
||||
return
|
||||
}
|
||||
Task {
|
||||
state.passwordStrengthScore = try? await services.authRepository.passwordStrength(
|
||||
email: state.userEmail,
|
||||
password: state.passwordText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify users email using the provided token
|
||||
///
|
||||
private func verifyUserEmail() async {
|
||||
// Hide the loading overlay when exiting this method, in case it hasn't been hidden yet.
|
||||
defer { coordinator.hideLoadingOverlay() }
|
||||
|
||||
if state.fromEmail {
|
||||
state.toast = Toast(text: Localizations.emailVerified)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,566 @@
|
||||
import AuthenticationServices
|
||||
import Networking
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
// MARK: - CompleteRegistrationProcessorTests
|
||||
|
||||
// swiftlint:disable:next type_body_length
|
||||
class CompleteRegistrationProcessorTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var authRepository: MockAuthRepository!
|
||||
var client: MockHTTPClient!
|
||||
var clientAuth: MockClientAuth!
|
||||
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
|
||||
var environmentService: MockEnvironmentService!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var subject: CompleteRegistrationProcessor!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
authRepository = MockAuthRepository()
|
||||
client = MockHTTPClient()
|
||||
clientAuth = MockClientAuth()
|
||||
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
|
||||
environmentService = MockEnvironmentService()
|
||||
errorReporter = MockErrorReporter()
|
||||
subject = CompleteRegistrationProcessor(
|
||||
coordinator: coordinator.asAnyCoordinator(),
|
||||
services: ServiceContainer.withMocks(
|
||||
authRepository: authRepository,
|
||||
clientService: MockClientService(auth: clientAuth),
|
||||
environmentService: environmentService,
|
||||
errorReporter: errorReporter,
|
||||
httpClient: client
|
||||
),
|
||||
state: CompleteRegistrationState(
|
||||
emailVerificationToken: "emailVerificationToken",
|
||||
userEmail: "example@email.com"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
authRepository = nil
|
||||
clientAuth = nil
|
||||
client = nil
|
||||
coordinator = nil
|
||||
errorReporter = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `perform(.appeared)` with EU region in state.
|
||||
func test_perform_appeared_setRegion_europe() async {
|
||||
subject.state.region = .europe
|
||||
await subject.perform(.appeared)
|
||||
XCTAssertEqual(subject.state.region, .europe)
|
||||
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultEU)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with nil region in state.
|
||||
func test_perform_appeared_setRegion_return() async {
|
||||
subject.state.region = nil
|
||||
await subject.perform(.appeared)
|
||||
XCTAssertEqual(subject.state.region, nil)
|
||||
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, nil)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` verify user email show toast.
|
||||
func test_perform_appeared_verifyuseremail_toast() async {
|
||||
subject.state.fromEmail = true
|
||||
await subject.perform(.appeared)
|
||||
XCTAssertEqual(subject.state.toast?.text, Localizations.emailVerified)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` verify user email show no toast.
|
||||
func test_perform_appeared_verifyuseremail_notoast() async {
|
||||
subject.state.fromEmail = false
|
||||
await subject.perform(.appeared)
|
||||
XCTAssertNil(subject.state.toast)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` verify user email hide loading.
|
||||
func test_perform_appeared_verifyuseremail_hideloading() async {
|
||||
coordinator.isLoadingOverlayShowing = true
|
||||
subject.state.fromEmail = true
|
||||
await subject.perform(.appeared)
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertNotNil(coordinator.loadingOverlaysShown)
|
||||
XCTAssertEqual(subject.state.toast?.text, Localizations.emailVerified)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` will still make the `CompleteRegistrationRequest` when the HIBP
|
||||
/// network request fails.
|
||||
func test_perform_checkPasswordAndCompleteRegistration_failure() async throws {
|
||||
subject.state = .fixture(isCheckDataBreachesToggleOn: true)
|
||||
|
||||
client.results = [.httpFailure(URLError(.timedOut) as Error), .httpSuccess(testData: .createAccountRequest)]
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
var dismissAction: DismissAction?
|
||||
if case let .dismissWithAction(onDismiss) = coordinator.routes.last {
|
||||
dismissAction = onDismiss
|
||||
}
|
||||
XCTAssertNotNil(dismissAction)
|
||||
dismissAction?.action()
|
||||
|
||||
XCTAssertEqual(client.requests.count, 2)
|
||||
XCTAssertEqual(client.requests[0].url, URL(string: "https://api.pwnedpasswords.com/range/e6b6a"))
|
||||
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(
|
||||
coordinator.loadingOverlaysShown,
|
||||
[
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when the password
|
||||
/// is weak and exposed. This also tests that the correct alert is presented.
|
||||
/// Additionally, this tests that tapping Yes on the alert creates the account.
|
||||
func test_perform_checkPasswordAndCompleteRegistration_exposedWeak_yesTapped() async throws {
|
||||
subject.state = .fixture(isCheckDataBreachesToggleOn: true, passwordStrengthScore: 1)
|
||||
|
||||
client.results = [.httpSuccess(testData: .hibpLeakedPasswords), .httpSuccess(testData: .createAccountRequest)]
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
try await alert.tapAction(title: Localizations.yes)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 2)
|
||||
XCTAssertEqual(client.requests[0].url, URL(string: "https://api.pwnedpasswords.com/range/e6b6a"))
|
||||
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
|
||||
XCTAssertEqual(coordinator.alertShown.last, Alert(
|
||||
title: Localizations.weakAndExposedMasterPassword,
|
||||
message: Localizations.weakPasswordIdentifiedAndFoundInADataBreachAlertDescription,
|
||||
alertActions: [
|
||||
AlertAction(title: Localizations.no, style: .cancel),
|
||||
AlertAction(title: Localizations.yes, style: .default) { _ in },
|
||||
]
|
||||
))
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(
|
||||
coordinator.loadingOverlaysShown,
|
||||
[
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when the password
|
||||
/// is strong and exposed. This also tests that the correct alert is presented.
|
||||
/// Additionally, this tests that tapping Yes on the alert creates the account.
|
||||
func test_perform_checkPasswordAndCompleteRegistration_exposedStrong_yesTapped() async throws {
|
||||
subject.state = .fixture(isCheckDataBreachesToggleOn: true, passwordStrengthScore: 3)
|
||||
|
||||
client.results = [.httpSuccess(testData: .hibpLeakedPasswords), .httpSuccess(testData: .createAccountRequest)]
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
try await alert.tapAction(title: Localizations.yes)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 2)
|
||||
XCTAssertEqual(client.requests[0].url, URL(string: "https://api.pwnedpasswords.com/range/e6b6a"))
|
||||
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
|
||||
XCTAssertEqual(coordinator.alertShown.last, Alert(
|
||||
title: Localizations.exposedMasterPassword,
|
||||
message: Localizations.passwordFoundInADataBreachAlertDescription,
|
||||
alertActions: [
|
||||
AlertAction(title: Localizations.no, style: .cancel),
|
||||
AlertAction(title: Localizations.yes, style: .default) { _ in },
|
||||
]
|
||||
))
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(
|
||||
coordinator.loadingOverlaysShown,
|
||||
[
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when the password
|
||||
/// is weak and unchecked against breaches. This also tests that the correct alert is presented.
|
||||
/// Additionally, this tests that tapping Yes on the alert creates the account.
|
||||
func test_perform_checkPasswordAndCompleteRegistration_uncheckedWeak_yesTapped() async throws {
|
||||
subject.state = .fixture(
|
||||
isCheckDataBreachesToggleOn: false,
|
||||
passwordText: "unexposed123",
|
||||
passwordStrengthScore: 2,
|
||||
retypePasswordText: "unexposed123"
|
||||
)
|
||||
|
||||
client.results = [.httpSuccess(testData: .createAccountRequest)]
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
try await alert.tapAction(title: Localizations.yes)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertEqual(client.requests[0].url, URL(string: "https://example.com/identity/accounts/register/finish"))
|
||||
XCTAssertEqual(coordinator.alertShown.last, Alert(
|
||||
title: Localizations.weakMasterPassword,
|
||||
message: Localizations.weakPasswordIdentifiedUseAStrongPasswordToProtectYourAccount,
|
||||
alertActions: [
|
||||
AlertAction(title: Localizations.no, style: .cancel),
|
||||
AlertAction(title: Localizations.yes, style: .default) { _ in },
|
||||
]
|
||||
))
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when the password
|
||||
/// is weak and unexposed. This also tests that the correct alert is presented.
|
||||
/// Additionally, this tests that tapping Yes on the alert creates the account.
|
||||
func test_perform_checkPasswordAndCompleteRegistration_unexposedWeak_yesTapped() async throws {
|
||||
subject.state = .fixture(
|
||||
isCheckDataBreachesToggleOn: true,
|
||||
passwordText: "unexposed123",
|
||||
passwordStrengthScore: 2,
|
||||
retypePasswordText: "unexposed123"
|
||||
)
|
||||
|
||||
client.results = [.httpSuccess(testData: .hibpLeakedPasswords), .httpSuccess(testData: .createAccountRequest)]
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
try await alert.tapAction(title: Localizations.yes)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 2)
|
||||
XCTAssertEqual(client.requests[0].url, URL(string: "https://api.pwnedpasswords.com/range/6bf92"))
|
||||
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
|
||||
XCTAssertEqual(coordinator.alertShown.last, Alert(
|
||||
title: Localizations.weakMasterPassword,
|
||||
message: Localizations.weakPasswordIdentifiedUseAStrongPasswordToProtectYourAccount,
|
||||
alertActions: [
|
||||
AlertAction(title: Localizations.no, style: .cancel),
|
||||
AlertAction(title: Localizations.yes, style: .default) { _ in },
|
||||
]
|
||||
))
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(
|
||||
coordinator.loadingOverlaysShown,
|
||||
[
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when the email has already been taken.
|
||||
func test_perform_completeRegistration_accountAlreadyExists() async {
|
||||
subject.state = .fixture()
|
||||
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: APITestData.createAccountAccountAlreadyExists.data
|
||||
)
|
||||
|
||||
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
|
||||
|
||||
client.result = .httpFailure(
|
||||
ServerError.error(errorResponse: errorResponse)
|
||||
)
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertEqual(
|
||||
coordinator.alertShown.last,
|
||||
.defaultAlert(
|
||||
title: Localizations.anErrorHasOccurred,
|
||||
message: "Email 'j@a.com' is already taken."
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when the password field is empty.
|
||||
func test_perform_completeRegistration_emptyPassword() async {
|
||||
subject.state = .fixture(passwordText: "", retypePasswordText: "")
|
||||
|
||||
client.result = .httpSuccess(testData: .createAccountRequest)
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 0)
|
||||
XCTAssertEqual(coordinator.alertShown.last, .validationFieldRequired(fieldName: "Master password"))
|
||||
XCTAssertTrue(coordinator.loadingOverlaysShown.isEmpty)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when the password hint is too long.
|
||||
func test_perform_completeRegistration_hintTooLong() async {
|
||||
subject.state = .fixture(passwordHintText: """
|
||||
ajajajajajajajajajajajajajajajajajajajajajajajajajajajajajajajajajajaj
|
||||
ajajajajajajajajajajajajajajajajajajajajajajajajajajajajajsjajajajajaj
|
||||
""")
|
||||
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: APITestData.createAccountHintTooLong.data
|
||||
)
|
||||
|
||||
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
|
||||
|
||||
client.result = .httpFailure(
|
||||
ServerError.error(errorResponse: errorResponse)
|
||||
)
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertEqual(
|
||||
coordinator.alertShown.last,
|
||||
.defaultAlert(
|
||||
title: Localizations.anErrorHasOccurred,
|
||||
message: "The field MasterPasswordHint must be a string with a maximum length of 50."
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when the email is in an invalid format.
|
||||
func test_perform_completeRegistration_invalidEmailFormat() async {
|
||||
subject.state = .fixture(userEmail: "∫@ø.com")
|
||||
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: APITestData.createAccountInvalidEmailFormat.data
|
||||
)
|
||||
|
||||
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
|
||||
|
||||
client.result = .httpFailure(
|
||||
ServerError.error(errorResponse: errorResponse)
|
||||
)
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertEqual(
|
||||
coordinator.alertShown.last,
|
||||
.defaultAlert(
|
||||
title: Localizations.anErrorHasOccurred,
|
||||
message: "The Email field is not a supported e-mail address format."
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when there is no internet connection.
|
||||
/// When the user taps `Try again`, the create account request is made again.
|
||||
func test_perform_completeRegistration_noInternetConnection() async throws {
|
||||
subject.state = .fixture()
|
||||
|
||||
let urlError = URLError(.notConnectedToInternet) as Error
|
||||
client.results = [.httpFailure(urlError), .httpSuccess(testData: .createAccountRequest)]
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert, Alert.networkResponseError(urlError) {
|
||||
await self.subject.perform(.completeRegistration)
|
||||
})
|
||||
|
||||
try await alert.tapAction(title: Localizations.tryAgain)
|
||||
|
||||
var dismissAction: DismissAction?
|
||||
if case let .dismissWithAction(onDismiss) = coordinator.routes.last {
|
||||
dismissAction = onDismiss
|
||||
}
|
||||
XCTAssertNotNil(dismissAction)
|
||||
dismissAction?.action()
|
||||
|
||||
XCTAssertEqual(client.requests.count, 2)
|
||||
XCTAssertEqual(client.requests[0].url, URL(string: "https://example.com/identity/accounts/register/finish"))
|
||||
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(
|
||||
coordinator.loadingOverlaysShown,
|
||||
[
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when password confirmation is incorrect.
|
||||
func test_perform_completeRegistration_passwordsDontMatch() async {
|
||||
subject.state = .fixture(passwordText: "123456789012", retypePasswordText: "123456789000")
|
||||
|
||||
client.result = .httpSuccess(testData: .createAccountRequest)
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 0)
|
||||
XCTAssertEqual(coordinator.alertShown.last, .passwordsDontMatch)
|
||||
XCTAssertTrue(coordinator.loadingOverlaysShown.isEmpty)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when the password isn't long enough.
|
||||
func test_perform_completeRegistration_passwordsTooShort() async {
|
||||
subject.state = .fixture(passwordText: "123", retypePasswordText: "123")
|
||||
|
||||
client.result = .httpSuccess(testData: .createAccountRequest)
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 0)
|
||||
XCTAssertEqual(coordinator.alertShown.last, .passwordIsTooShort)
|
||||
XCTAssertTrue(coordinator.loadingOverlaysShown.isEmpty)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.completeRegistration` presents an alert when the request times out.
|
||||
/// When the user taps `Try again`, the create account request is made again.
|
||||
func test_perform_completeRegistration_timeout() async throws {
|
||||
subject.state = .fixture()
|
||||
|
||||
let urlError = URLError(.timedOut) as Error
|
||||
client.results = [.httpFailure(urlError), .httpSuccess(testData: .createAccountRequest)]
|
||||
|
||||
await subject.perform(.completeRegistration)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.message, urlError.localizedDescription)
|
||||
|
||||
try await alert.tapAction(title: Localizations.tryAgain)
|
||||
|
||||
var dismissAction: DismissAction?
|
||||
if case let .dismissWithAction(onDismiss) = coordinator.routes.last {
|
||||
dismissAction = onDismiss
|
||||
}
|
||||
XCTAssertNotNil(dismissAction)
|
||||
dismissAction?.action()
|
||||
|
||||
XCTAssertEqual(client.requests.count, 2)
|
||||
XCTAssertEqual(client.requests[0].url, URL(string: "https://example.com/identity/accounts/register/finish"))
|
||||
XCTAssertEqual(client.requests[1].url, URL(string: "https://example.com/identity/accounts/register/finish"))
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(
|
||||
coordinator.loadingOverlaysShown,
|
||||
[
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.dismiss` dismisses the view.
|
||||
func test_receive_dismiss() {
|
||||
subject.receive(.dismiss)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismissPresented)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.passwordHintTextChanged(_:)` updates the state to reflect the change.
|
||||
func test_receive_passwordHintTextChanged() {
|
||||
subject.state.passwordHintText = ""
|
||||
XCTAssertTrue(subject.state.passwordHintText.isEmpty)
|
||||
|
||||
subject.receive(.passwordHintTextChanged("updated hint"))
|
||||
XCTAssertTrue(subject.state.passwordHintText == "updated hint")
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.passwordTextChanged(_:)` updates the state to reflect the change.
|
||||
func test_receive_passwordTextChanged() {
|
||||
subject.state.passwordText = ""
|
||||
XCTAssertTrue(subject.state.passwordText.isEmpty)
|
||||
|
||||
subject.receive(.passwordTextChanged("updated password"))
|
||||
XCTAssertTrue(subject.state.passwordText == "updated password")
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.passwordTextChanged(_:)` updates the password strength score based on
|
||||
/// the entered password.
|
||||
func test_receive_passwordTextChanged_updatesPasswordStrength() {
|
||||
subject.state.userEmail = "user@bitwarden.com"
|
||||
subject.receive(.passwordTextChanged(""))
|
||||
XCTAssertNil(subject.state.passwordStrengthScore)
|
||||
XCTAssertNil(authRepository.passwordStrengthPassword)
|
||||
|
||||
authRepository.passwordStrengthResult = 0
|
||||
subject.receive(.passwordTextChanged("T"))
|
||||
waitFor(subject.state.passwordStrengthScore == 0)
|
||||
XCTAssertEqual(subject.state.passwordStrengthScore, 0)
|
||||
XCTAssertEqual(authRepository.passwordStrengthEmail, "user@bitwarden.com")
|
||||
XCTAssertEqual(authRepository.passwordStrengthPassword, "T")
|
||||
|
||||
authRepository.passwordStrengthResult = 4
|
||||
subject.receive(.passwordTextChanged("TestPassword1234567890!@#"))
|
||||
waitFor(subject.state.passwordStrengthScore == 4)
|
||||
XCTAssertEqual(subject.state.passwordStrengthScore, 4)
|
||||
XCTAssertEqual(authRepository.passwordStrengthEmail, "user@bitwarden.com")
|
||||
XCTAssertEqual(authRepository.passwordStrengthPassword, "TestPassword1234567890!@#")
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.retypePasswordTextChanged(_:)` updates the state to reflect the change.
|
||||
func test_receive_retypePasswordTextChanged() {
|
||||
subject.state.retypePasswordText = ""
|
||||
XCTAssertTrue(subject.state.retypePasswordText.isEmpty)
|
||||
|
||||
subject.receive(.retypePasswordTextChanged("updated re-type"))
|
||||
XCTAssertTrue(subject.state.retypePasswordText == "updated re-type")
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.toggleCheckDataBreaches(_:)` updates the state to reflect the change.
|
||||
func test_receive_toggleCheckDataBreaches() {
|
||||
subject.receive(.toggleCheckDataBreaches(false))
|
||||
XCTAssertFalse(subject.state.isCheckDataBreachesToggleOn)
|
||||
|
||||
subject.receive(.toggleCheckDataBreaches(true))
|
||||
XCTAssertTrue(subject.state.isCheckDataBreachesToggleOn)
|
||||
|
||||
subject.receive(.toggleCheckDataBreaches(true))
|
||||
XCTAssertTrue(subject.state.isCheckDataBreachesToggleOn)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.togglePasswordVisibility(_:)` updates the state to reflect the change.
|
||||
func test_receive_togglePasswordVisibility() {
|
||||
subject.state.arePasswordsVisible = false
|
||||
|
||||
subject.receive(.togglePasswordVisibility(true))
|
||||
XCTAssertTrue(subject.state.arePasswordsVisible)
|
||||
|
||||
subject.receive(.togglePasswordVisibility(true))
|
||||
XCTAssertTrue(subject.state.arePasswordsVisible)
|
||||
|
||||
subject.receive(.togglePasswordVisibility(false))
|
||||
XCTAssertFalse(subject.state.arePasswordsVisible)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.showToast` show toast.
|
||||
func test_receive_showToast() {
|
||||
subject.receive(.toastShown(Toast(text: "example")))
|
||||
XCTAssertEqual(subject.state.toast?.text, "example")
|
||||
}
|
||||
// swiftlint:disable:next file_length
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - CompleteRegistrationState
|
||||
|
||||
/// An object that defines the current state of a `CompleteRegistrationView`.
|
||||
///
|
||||
struct CompleteRegistrationState: Equatable {
|
||||
// MARK: Properties
|
||||
|
||||
/// Whether passwords are visible in the view's text fields.
|
||||
var arePasswordsVisible: Bool = false
|
||||
|
||||
/// Token needed to complete registration
|
||||
var emailVerificationToken: String
|
||||
|
||||
/// Whether the user came from email AppLink
|
||||
var fromEmail: Bool = false
|
||||
|
||||
/// Whether the check for data breaches toggle is on.
|
||||
var isCheckDataBreachesToggleOn: Bool = true
|
||||
|
||||
/// Whether the password is considered weak.
|
||||
var isWeakPassword: Bool {
|
||||
guard let passwordStrengthScore else { return false }
|
||||
return passwordStrengthScore < 3
|
||||
}
|
||||
|
||||
/// The text in the password hint text field.
|
||||
var passwordHintText: String = ""
|
||||
|
||||
/// The text in the master password text field.
|
||||
var passwordText: String = ""
|
||||
|
||||
/// A scoring metric that represents the strength of the entered password. The score ranges from
|
||||
/// 0-4 (weak to strong password).
|
||||
var passwordStrengthScore: UInt8?
|
||||
|
||||
/// The password visibility icon used in the view's text fields.
|
||||
var passwordVisibleIcon: ImageAsset {
|
||||
arePasswordsVisible ? Asset.Images.hidden : Asset.Images.visible
|
||||
}
|
||||
|
||||
/// The region where the account should be created
|
||||
var region: RegionType?
|
||||
|
||||
/// The text in the re-type password text field.
|
||||
var retypePasswordText: String = ""
|
||||
|
||||
/// The email of the user that is creating the account.
|
||||
var userEmail: String
|
||||
|
||||
/// A toast message to show in the view.
|
||||
var toast: Toast?
|
||||
|
||||
// MARK: Computed Properties
|
||||
|
||||
/// Text with user email in bold
|
||||
var headelineTextBoldEmail: String {
|
||||
Localizations.finishCreatingYourAccountForXBySettingAPassword("**\(userEmail)**")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,172 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - CompleteRegistrationView
|
||||
|
||||
/// A view that allows the user to create an account.
|
||||
///
|
||||
struct CompleteRegistrationView: View {
|
||||
// MARK: Properties
|
||||
|
||||
/// The store used to render the view.
|
||||
@ObservedObject var store: Store<CompleteRegistrationState, CompleteRegistrationAction, CompleteRegistrationEffect>
|
||||
|
||||
// MARK: View
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(LocalizedStringKey(store.state.headelineTextBoldEmail))
|
||||
.tint(Asset.Colors.textPrimary.swiftUIColor)
|
||||
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.styleGuide(.callout)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
passwordField
|
||||
.padding(.bottom, 8)
|
||||
|
||||
passwordStrengthIndicator
|
||||
}
|
||||
|
||||
retypePassword
|
||||
|
||||
passwordHint
|
||||
|
||||
VStack(spacing: 24) {
|
||||
checkBreachesToggle
|
||||
.padding(.top, 8)
|
||||
|
||||
createAccountButton
|
||||
}
|
||||
}
|
||||
.animation(.default, value: store.state.passwordStrengthScore)
|
||||
.navigationBar(title: Localizations.setPassword, titleDisplayMode: .inline)
|
||||
.scrollView()
|
||||
.toolbar {
|
||||
cancelToolbarItem {
|
||||
store.send(.dismiss)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await store.perform(.appeared)
|
||||
}
|
||||
.toast(store.binding(
|
||||
get: \.toast,
|
||||
send: CompleteRegistrationAction.toastShown
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: Private views
|
||||
|
||||
/// A toggle to check the user's password for security breaches.
|
||||
private var checkBreachesToggle: some View {
|
||||
Toggle(isOn: store.binding(
|
||||
get: \.isCheckDataBreachesToggleOn,
|
||||
send: CompleteRegistrationAction.toggleCheckDataBreaches
|
||||
)) {
|
||||
Text(Localizations.checkKnownDataBreachesForThisPassword)
|
||||
.styleGuide(.footnote)
|
||||
}
|
||||
.accessibilityIdentifier("CheckExposedMasterPasswordToggle")
|
||||
.toggleStyle(.bitwarden)
|
||||
.id(ViewIdentifier.CompleteRegistration.checkBreaches)
|
||||
}
|
||||
|
||||
/// The text fields for the user's email and password.
|
||||
private var passwordField: some View {
|
||||
BitwardenTextField(
|
||||
title: Localizations.masterPassword,
|
||||
text: store.binding(
|
||||
get: \.passwordText,
|
||||
send: CompleteRegistrationAction.passwordTextChanged
|
||||
),
|
||||
accessibilityIdentifier: "MasterPasswordEntry",
|
||||
passwordVisibilityAccessibilityId: "PasswordVisibilityToggle",
|
||||
isPasswordVisible: store.binding(
|
||||
get: \.arePasswordsVisible,
|
||||
send: CompleteRegistrationAction.togglePasswordVisibility
|
||||
)
|
||||
)
|
||||
.textFieldConfiguration(.password)
|
||||
}
|
||||
|
||||
/// The master password hint.
|
||||
private var passwordHint: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
BitwardenTextField(
|
||||
title: Localizations.masterPasswordHint,
|
||||
text: store.binding(
|
||||
get: \.passwordHintText,
|
||||
send: CompleteRegistrationAction.passwordHintTextChanged
|
||||
),
|
||||
accessibilityIdentifier: "MasterPasswordHintLabel"
|
||||
)
|
||||
|
||||
Text(Localizations.masterPasswordHintDescription)
|
||||
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
|
||||
.styleGuide(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
/// The password strength indicator.
|
||||
private var passwordStrengthIndicator: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Group {
|
||||
Text(Localizations.important + ": ").bold() +
|
||||
Text(Localizations.yourMasterPasswordCannotBeRecoveredIfYouForgetItXCharactersMinimum(
|
||||
Constants.minimumPasswordCharacters)
|
||||
)
|
||||
}
|
||||
.styleGuide(.footnote)
|
||||
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
|
||||
.padding(.bottom, 16)
|
||||
|
||||
PasswordStrengthIndicator(
|
||||
passwordStrengthScore: store.state.passwordStrengthScore
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The text field for re-typing the master password.
|
||||
private var retypePassword: some View {
|
||||
BitwardenTextField(
|
||||
title: Localizations.retypeMasterPassword,
|
||||
text: store.binding(
|
||||
get: \.retypePasswordText,
|
||||
send: CompleteRegistrationAction.retypePasswordTextChanged
|
||||
),
|
||||
accessibilityIdentifier: "ConfirmMasterPasswordEntry",
|
||||
passwordVisibilityAccessibilityId: "ConfirmPasswordVisibilityToggle",
|
||||
isPasswordVisible: store.binding(
|
||||
get: \.arePasswordsVisible,
|
||||
send: CompleteRegistrationAction.togglePasswordVisibility
|
||||
)
|
||||
)
|
||||
.textFieldConfiguration(.password)
|
||||
}
|
||||
|
||||
/// The button pressed when the user attempts to create the account.
|
||||
private var createAccountButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await store.perform(.completeRegistration)
|
||||
}
|
||||
} label: {
|
||||
Text(Localizations.createAccount)
|
||||
}
|
||||
.accessibilityIdentifier("CreateAccountButton")
|
||||
.buttonStyle(.primary())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Previews
|
||||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
CompleteRegistrationView(store: Store(processor: StateProcessor(
|
||||
state: CompleteRegistrationState(
|
||||
emailVerificationToken: "emailVerificationToken",
|
||||
userEmail: "example@bitwarden.com"
|
||||
))))
|
||||
}
|
||||
#endif
|
||||
@ -0,0 +1,133 @@
|
||||
import SnapshotTesting
|
||||
import SwiftUI
|
||||
import ViewInspector
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
// MARK: - CompleteRegistrationViewTests
|
||||
|
||||
class CompleteRegistrationViewTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var processor: MockProcessor<CompleteRegistrationState, CompleteRegistrationAction, CompleteRegistrationEffect>!
|
||||
var subject: CompleteRegistrationView!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
processor = MockProcessor(state: CompleteRegistrationState(
|
||||
emailVerificationToken: "emailVerificationToken",
|
||||
userEmail: "email@example.com"
|
||||
))
|
||||
let store = Store(processor: processor)
|
||||
subject = CompleteRegistrationView(store: store)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
processor = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// Tapping the cancel button dispatches the `.dismiss` action.
|
||||
func test_cancelButton_tap() throws {
|
||||
let button = try subject.inspect().find(button: Localizations.cancel)
|
||||
try button.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .dismiss)
|
||||
}
|
||||
|
||||
/// Tapping the check for security breaches toggle dispatches the `.toggleCheckDataBreaches()` action.
|
||||
func test_checkBreachesToggle_tap() throws {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
throw XCTSkip("Unable to run test in iOS 16, keep an eye on ViewInspector to see if it gets updated.")
|
||||
}
|
||||
let toggle = try subject.inspect().find(viewWithId: ViewIdentifier.CompleteRegistration.checkBreaches).toggle()
|
||||
try toggle.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .toggleCheckDataBreaches(true))
|
||||
}
|
||||
|
||||
/// Updating the text field dispatches the `.passwordHintTextChanged()` action.
|
||||
func test_hintField_updateValue() throws {
|
||||
let textfield = try subject.inspect().find(viewWithId: Localizations.masterPasswordHint).textField()
|
||||
try textfield.setInput("text")
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .passwordHintTextChanged("text"))
|
||||
}
|
||||
|
||||
/// Updating the text field dispatches the `.passwordTextChanged()` action.
|
||||
func test_masterPasswordField_updateValue() throws {
|
||||
processor.state.arePasswordsVisible = true
|
||||
let textfield = try subject.inspect().find(viewWithId: Localizations.masterPassword).textField()
|
||||
try textfield.setInput("text")
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .passwordTextChanged("text"))
|
||||
}
|
||||
|
||||
/// Tapping the password visibility icon changes whether or not passwords are visible.
|
||||
func test_passwordVisibility_tap() throws {
|
||||
processor.state.arePasswordsVisible = false
|
||||
let visibilityIcon = try subject.inspect().find(
|
||||
viewWithAccessibilityLabel: Localizations.passwordIsNotVisibleTapToShow
|
||||
).button()
|
||||
try visibilityIcon.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .togglePasswordVisibility(true))
|
||||
}
|
||||
|
||||
/// Updating the text field dispatches the `.retypePasswordTextChanged()` action.
|
||||
func test_retypePasswordField_updateValue() throws {
|
||||
processor.state.arePasswordsVisible = true
|
||||
let textfield = try subject.inspect().find(viewWithId: Localizations.retypeMasterPassword).textField()
|
||||
try textfield.setInput("text")
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .retypePasswordTextChanged("text"))
|
||||
}
|
||||
|
||||
/// Tapping the submit button performs the `.CompleteRegistration` effect.
|
||||
func test_createAccountButton_tap() throws {
|
||||
let button = try subject.inspect().find(button: Localizations.createAccount)
|
||||
try button.tap()
|
||||
|
||||
waitFor(!processor.effects.isEmpty)
|
||||
|
||||
XCTAssertEqual(processor.effects.last, .completeRegistration)
|
||||
}
|
||||
|
||||
// MARK: Snapshots
|
||||
|
||||
/// Tests the view renders correctly when the text fields are all empty.
|
||||
func test_snapshot_empty() {
|
||||
assertSnapshots(matching: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
|
||||
}
|
||||
|
||||
/// Tests the view renders correctly when text fields are hidden.
|
||||
func test_snapshot_textFields_hidden() throws {
|
||||
processor.state.arePasswordsVisible = false
|
||||
processor.state.userEmail = "email@example.com"
|
||||
processor.state.passwordText = "12345"
|
||||
processor.state.retypePasswordText = "12345"
|
||||
processor.state.passwordHintText = "wink wink"
|
||||
processor.state.passwordStrengthScore = 0
|
||||
|
||||
assertSnapshot(matching: subject, as: .defaultPortrait)
|
||||
}
|
||||
|
||||
/// Tests the view renders correctly when the text fields are all populated.
|
||||
func test_snapshot_textFields_populated() throws {
|
||||
processor.state.arePasswordsVisible = true
|
||||
processor.state.userEmail = "email@example.com"
|
||||
processor.state.passwordText = "12345"
|
||||
processor.state.retypePasswordText = "12345"
|
||||
processor.state.passwordHintText = "wink wink"
|
||||
processor.state.passwordStrengthScore = 0
|
||||
|
||||
assertSnapshots(matching: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
|
||||
}
|
||||
|
||||
/// Tests the view renders correctly when the toggles are on.
|
||||
func test_snapshot_toggles_on() throws {
|
||||
processor.state.isCheckDataBreachesToggleOn = true
|
||||
|
||||
assertSnapshot(matching: subject, as: .defaultPortrait)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
extension CompleteRegistrationState {
|
||||
static func fixture(
|
||||
arePasswordsVisible: Bool = false,
|
||||
emailVerificationToken: String = "emailVerificationToken",
|
||||
isCheckDataBreachesToggleOn: Bool = false,
|
||||
passwordHintText: String = "",
|
||||
passwordText: String = "password1234",
|
||||
passwordStrengthScore: UInt8 = 3,
|
||||
userEmail: String = "email@example.com",
|
||||
retypePasswordText: String = "password1234"
|
||||
) -> Self {
|
||||
CompleteRegistrationState(
|
||||
arePasswordsVisible: arePasswordsVisible,
|
||||
emailVerificationToken: emailVerificationToken,
|
||||
isCheckDataBreachesToggleOn: isCheckDataBreachesToggleOn,
|
||||
passwordHintText: passwordHintText,
|
||||
passwordText: passwordText,
|
||||
passwordStrengthScore: passwordStrengthScore,
|
||||
retypePasswordText: retypePasswordText,
|
||||
userEmail: userEmail
|
||||
)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 172 KiB |
@ -11,9 +11,6 @@ enum LandingAction: Equatable {
|
||||
/// A forwarded profile switcher action.
|
||||
case profileSwitcher(ProfileSwitcherAction)
|
||||
|
||||
/// The region button was pressed.
|
||||
case regionPressed
|
||||
|
||||
/// The value for the remember me toggle was changed.
|
||||
case rememberMeChanged(Bool)
|
||||
|
||||
|
||||
@ -10,4 +10,7 @@ enum LandingEffect: Equatable {
|
||||
|
||||
/// A Profile Switcher Effect.
|
||||
case profileSwitcher(ProfileSwitcherEffect)
|
||||
|
||||
/// The region button was pressed.
|
||||
case regionPressed
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
|
||||
|
||||
typealias Services = HasAppSettingsStore
|
||||
& HasAuthRepository
|
||||
& HasConfigService
|
||||
& HasEnvironmentService
|
||||
& HasErrorReporter
|
||||
& HasStateService
|
||||
@ -22,6 +23,13 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
|
||||
/// The services required by this processor.
|
||||
private let services: Services
|
||||
|
||||
/// Helper class with region specific functions
|
||||
private lazy var regionHelper = RegionHelper(
|
||||
coordinator: coordinator,
|
||||
delegate: self,
|
||||
stateService: services.stateService
|
||||
)
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `LandingProcessor`.
|
||||
@ -43,7 +51,6 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
|
||||
var state = state
|
||||
state.email = rememberedEmail ?? ""
|
||||
state.isRememberMeOn = rememberedEmail != nil
|
||||
|
||||
super.init(state: state)
|
||||
}
|
||||
|
||||
@ -52,26 +59,33 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
|
||||
override func perform(_ effect: LandingEffect) async {
|
||||
switch effect {
|
||||
case .appeared:
|
||||
await loadRegion()
|
||||
await regionHelper.loadRegion()
|
||||
await refreshProfileState()
|
||||
case .continuePressed:
|
||||
updateRememberedEmail()
|
||||
await validateEmailAndContinue()
|
||||
case let .profileSwitcher(profileEffect):
|
||||
await handleProfileSwitcherEffect(profileEffect)
|
||||
case .regionPressed:
|
||||
await regionHelper.presentRegionSelectorAlert(
|
||||
title: Localizations.loggingInOn,
|
||||
currentRegion: state.region
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override func receive(_ action: LandingAction) {
|
||||
switch action {
|
||||
case .createAccountPressed:
|
||||
coordinator.navigate(to: .createAccount)
|
||||
if state.emailVerificationFeatureFlag {
|
||||
coordinator.navigate(to: .startRegistration, context: self)
|
||||
} else {
|
||||
coordinator.navigate(to: .createAccount)
|
||||
}
|
||||
case let .emailChanged(newValue):
|
||||
state.email = newValue
|
||||
case let .profileSwitcher(profileAction):
|
||||
handleProfileSwitcherAction(profileAction)
|
||||
case .regionPressed:
|
||||
presentRegionSelectionAlert()
|
||||
case let .rememberMeChanged(newValue):
|
||||
state.isRememberMeOn = newValue
|
||||
if !newValue {
|
||||
@ -84,21 +98,14 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
|
||||
|
||||
// MARK: Private Methods
|
||||
|
||||
/// Sets the region to the last used region.
|
||||
/// Sets the feature flag value to be used.
|
||||
///
|
||||
private func loadRegion() async {
|
||||
guard let urls = await services.stateService.getPreAuthEnvironmentUrls() else {
|
||||
await setRegion(.unitedStates, urls: .defaultUS)
|
||||
return
|
||||
}
|
||||
|
||||
if urls.base == EnvironmentUrlData.defaultUS.base {
|
||||
await setRegion(.unitedStates, urls: urls)
|
||||
} else if urls.base == EnvironmentUrlData.defaultEU.base {
|
||||
await setRegion(.europe, urls: urls)
|
||||
} else {
|
||||
await setRegion(.selfHosted, urls: urls)
|
||||
}
|
||||
private func loadFeatureFlag() async {
|
||||
state.emailVerificationFeatureFlag = await services.configService.getFeatureFlag(
|
||||
FeatureFlag.emailVerification,
|
||||
defaultValue: false,
|
||||
forceRefresh: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate the currently entered email address and navigate to the login screen.
|
||||
@ -120,43 +127,6 @@ class LandingProcessor: StateProcessor<LandingState, LandingAction, LandingEffec
|
||||
coordinator.navigate(to: .login(username: email))
|
||||
}
|
||||
|
||||
/// Builds an alert for region selection and navigates to the alert.
|
||||
///
|
||||
private func presentRegionSelectionAlert() {
|
||||
let actions = RegionType.allCases.map { region in
|
||||
AlertAction(title: region.baseUrlDescription, style: .default) { [weak self] _ in
|
||||
if let urls = region.defaultURLs {
|
||||
await self?.setRegion(region, urls: urls)
|
||||
} else {
|
||||
self?.coordinator.navigate(
|
||||
to: .selfHosted(currentRegion: self?.state.region ?? .unitedStates),
|
||||
context: self
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
let cancelAction = AlertAction(title: Localizations.cancel, style: .cancel)
|
||||
let alert = Alert(
|
||||
title: Localizations.loggingInOn,
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet,
|
||||
alertActions: actions + [cancelAction]
|
||||
)
|
||||
coordinator.showAlert(alert)
|
||||
}
|
||||
|
||||
/// Sets the region and the URLs to use.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - region: The region to use.
|
||||
/// - urls: The URLs that the app should use for the region.
|
||||
///
|
||||
private func setRegion(_ region: RegionType, urls: EnvironmentUrlData) async {
|
||||
guard !urls.isEmpty else { return }
|
||||
await services.environmentService.setPreAuthURLs(urls: urls)
|
||||
state.region = region
|
||||
}
|
||||
|
||||
/// Updates the value of `rememberedEmail` in the app settings store with the `email` value in `state`.
|
||||
///
|
||||
private func updateRememberedEmail() {
|
||||
@ -222,7 +192,36 @@ extension LandingProcessor: ProfileSwitcherHandler {
|
||||
|
||||
extension LandingProcessor: SelfHostedProcessorDelegate {
|
||||
func didSaveEnvironment(urls: EnvironmentUrlData) async {
|
||||
await setRegion(.selfHosted, urls: urls)
|
||||
await setRegion(.selfHosted, urls)
|
||||
state.toast = Toast(text: Localizations.environmentSaved)
|
||||
await regionHelper.loadRegion()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - StartRegistrationDelegate
|
||||
|
||||
extension LandingProcessor: StartRegistrationDelegate {
|
||||
func didChangeRegion() async {
|
||||
await regionHelper.loadRegion()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RegionDelegate
|
||||
|
||||
extension LandingProcessor: RegionDelegate {
|
||||
/// Sets the region and the URLs to use.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - region: The region to use.
|
||||
/// - urls: The URLs that the app should use for the region.
|
||||
///
|
||||
func setRegion(_ region: RegionType, _ urls: EnvironmentUrlData) async {
|
||||
guard !urls.isEmpty else { return }
|
||||
await services.environmentService.setPreAuthURLs(urls: urls)
|
||||
state.region = region
|
||||
// After setting a new region, feature flags need to be reloaded
|
||||
Task {
|
||||
await loadFeatureFlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
|
||||
var appSettingsStore: MockAppSettingsStore!
|
||||
var authRepository: MockAuthRepository!
|
||||
var configService: MockConfigService!
|
||||
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
|
||||
var environmentService: MockEnvironmentService!
|
||||
var errorReporter: MockErrorReporter!
|
||||
@ -22,6 +23,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
|
||||
appSettingsStore = MockAppSettingsStore()
|
||||
authRepository = MockAuthRepository()
|
||||
configService = MockConfigService()
|
||||
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
|
||||
environmentService = MockEnvironmentService()
|
||||
errorReporter = MockErrorReporter()
|
||||
@ -31,6 +33,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
let services = ServiceContainer.withMocks(
|
||||
appSettingsStore: appSettingsStore,
|
||||
authRepository: authRepository,
|
||||
configService: configService,
|
||||
environmentService: environmentService,
|
||||
errorReporter: errorReporter,
|
||||
stateService: stateService
|
||||
@ -47,6 +50,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
|
||||
authRepository = nil
|
||||
appSettingsStore = nil
|
||||
configService = nil
|
||||
coordinator = nil
|
||||
environmentService = nil
|
||||
errorReporter = nil
|
||||
@ -56,9 +60,22 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `didChangeRegion(urls:)` update URLs when they change on the StartRegistration modal
|
||||
func test_didChangeRegion() async {
|
||||
stateService.preAuthEnvironmentUrls = EnvironmentUrlData(base: .example)
|
||||
subject.state.region = .unitedStates
|
||||
await subject.didChangeRegion()
|
||||
XCTAssertEqual(subject.state.region, .selfHosted)
|
||||
XCTAssertEqual(
|
||||
environmentService.setPreAuthEnvironmentUrlsData,
|
||||
EnvironmentUrlData(base: .example)
|
||||
)
|
||||
}
|
||||
|
||||
/// `didSaveEnvironment(urls:)` with URLs sets the region to self-hosted and sets the URLs in
|
||||
/// the environment.
|
||||
func test_didSaveEnvironment() async {
|
||||
stateService.preAuthEnvironmentUrls = EnvironmentUrlData(base: .example)
|
||||
subject.state.region = .unitedStates
|
||||
await subject.didSaveEnvironment(urls: EnvironmentUrlData(base: .example))
|
||||
XCTAssertEqual(subject.state.region, .selfHosted)
|
||||
@ -71,6 +88,7 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
|
||||
/// `didSaveEnvironment(urls:)` with empty URLs doesn't change the region or the environment URLs.
|
||||
func test_didSaveEnvironment_empty() async {
|
||||
stateService.preAuthEnvironmentUrls = EnvironmentUrlData()
|
||||
subject.state.region = .unitedStates
|
||||
await subject.didSaveEnvironment(urls: EnvironmentUrlData())
|
||||
XCTAssertEqual(subject.state.region, .unitedStates)
|
||||
@ -112,6 +130,42 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultUS)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with feature flag for .emailVerification set to true
|
||||
func test_perform_appeared_loadsFeatureFlag_true() async {
|
||||
configService.featureFlagsBool[.emailVerification] = true
|
||||
subject.state.emailVerificationFeatureFlag = false
|
||||
|
||||
let task = Task {
|
||||
await subject.perform(.appeared)
|
||||
}
|
||||
await task.value
|
||||
XCTAssertTrue(subject.state.emailVerificationFeatureFlag)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with feature flag for .emailVerification set to false
|
||||
func test_perform_appeared_loadsFeatureFlag_false() async {
|
||||
configService.featureFlagsBool[.emailVerification] = false
|
||||
subject.state.emailVerificationFeatureFlag = true
|
||||
|
||||
let task = Task {
|
||||
await subject.perform(.appeared)
|
||||
}
|
||||
await task.value
|
||||
XCTAssertFalse(subject.state.emailVerificationFeatureFlag)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with feature flag defaulting to false
|
||||
func test_perform_appeared_loadsFeatureFlag_nil() async {
|
||||
configService.featureFlagsBool[.emailVerification] = nil
|
||||
subject.state.emailVerificationFeatureFlag = true
|
||||
|
||||
let task = Task {
|
||||
await subject.perform(.appeared)
|
||||
}
|
||||
await task.value
|
||||
XCTAssertFalse(subject.state.emailVerificationFeatureFlag)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with an active account and accounts should yield a profile switcher state.
|
||||
func test_perform_appeared_profiles_single_active() async {
|
||||
let profile = ProfileSwitcherItem.fixture()
|
||||
@ -358,12 +412,20 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
XCTAssertTrue(subject.state.isRememberMeOn)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.createAccountPressed` navigates to the create account screen.
|
||||
func test_receive_createAccountPressed() {
|
||||
/// `receive(_:)` with `.createAccountPressed` navigates to the create account screen if feature flag is `false`.
|
||||
func test_receive_createAccountPressed_ff_false() {
|
||||
subject.state.emailVerificationFeatureFlag = false
|
||||
subject.receive(.createAccountPressed)
|
||||
XCTAssertEqual(coordinator.routes.last, .createAccount)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.createAccountPressed` navigates to the start registration screen if feature flag is `true`.
|
||||
func test_receive_createAccountPressed_ff_true() {
|
||||
subject.state.emailVerificationFeatureFlag = true
|
||||
subject.receive(.createAccountPressed)
|
||||
XCTAssertEqual(coordinator.routes.last, .startRegistration)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.emailChanged` and an empty value updates the state to reflect the changes.
|
||||
func test_receive_emailChanged_empty() {
|
||||
subject.state.email = "email@example.com"
|
||||
@ -382,11 +444,11 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
XCTAssertTrue(subject.state.isContinueButtonEnabled)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.regionPressed` navigates to the region selection screen.
|
||||
func test_receive_regionPressed() async throws {
|
||||
subject.receive(.regionPressed)
|
||||
/// `perform(_:)` with `.regionPressed` navigates to the region selection screen.
|
||||
func test_perform_regionPressed() async throws {
|
||||
await subject.perform(.regionPressed)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
var alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.title, Localizations.loggingInOn)
|
||||
XCTAssertNil(alert.message)
|
||||
XCTAssertEqual(alert.alertActions.count, 4)
|
||||
@ -395,10 +457,14 @@ class LandingProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
try await alert.tapAction(title: "bitwarden.com")
|
||||
XCTAssertEqual(subject.state.region, .unitedStates)
|
||||
|
||||
await subject.perform(.regionPressed)
|
||||
alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.alertActions[1].title, "bitwarden.eu")
|
||||
try await alert.tapAction(title: "bitwarden.eu")
|
||||
XCTAssertEqual(subject.state.region, .europe)
|
||||
|
||||
await subject.perform(.regionPressed)
|
||||
alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.alertActions[2].title, Localizations.selfHosted)
|
||||
try await alert.tapAction(title: Localizations.selfHosted)
|
||||
XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .europe))
|
||||
|
||||
@ -25,6 +25,9 @@ struct LandingState: Equatable {
|
||||
/// A toast message to show in the view.
|
||||
var toast: Toast?
|
||||
|
||||
/// Flag to use email verification or not
|
||||
var emailVerificationFeatureFlag: Bool = true
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `LandingState`.
|
||||
|
||||
@ -94,24 +94,12 @@ struct LandingView: View {
|
||||
Task { await store.perform(.continuePressed) }
|
||||
}
|
||||
|
||||
Button {
|
||||
store.send(.regionPressed)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Group {
|
||||
Text("\(Localizations.loggingInOn): ")
|
||||
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)
|
||||
+ Text(store.state.region.baseUrlDescription)
|
||||
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
}
|
||||
.styleGuide(.subheadline)
|
||||
|
||||
Image(decorative: Asset.Images.downAngle)
|
||||
.scaledFrame(width: 12, height: 12)
|
||||
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
}
|
||||
RegionSelector(
|
||||
selectorLabel: Localizations.loggingInOn,
|
||||
regionName: store.state.region.baseUrlDescription
|
||||
) {
|
||||
await store.perform(.regionPressed)
|
||||
}
|
||||
.accessibilityIdentifier("RegionSelectorDropdown")
|
||||
|
||||
Toggle(Localizations.rememberMe, isOn: store.binding(
|
||||
get: { $0.isRememberMeOn },
|
||||
|
||||
@ -72,7 +72,8 @@ class LandingViewTests: BitwardenTestCase {
|
||||
button: "\(Localizations.loggingInOn): \(RegionType.unitedStates.baseUrlDescription)"
|
||||
)
|
||||
try button.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .regionPressed)
|
||||
waitFor(processor.effects.last != nil)
|
||||
XCTAssertEqual(processor.effects.last, .regionPressed)
|
||||
}
|
||||
|
||||
/// Tapping the remember me toggle dispatches the `.rememberMeChanged` action.
|
||||
|
||||
@ -57,7 +57,7 @@ final class SelfHostedProcessor: StateProcessor<SelfHostedState, SelfHostedActio
|
||||
case let .apiUrlChanged(url):
|
||||
state.apiServerUrl = url
|
||||
case .dismiss:
|
||||
coordinator.navigate(to: .dismiss)
|
||||
coordinator.navigate(to: .dismissPresented)
|
||||
case let .iconsUrlChanged(url):
|
||||
state.iconsServerUrl = url
|
||||
case let .identityUrlChanged(url):
|
||||
@ -107,6 +107,6 @@ final class SelfHostedProcessor: StateProcessor<SelfHostedState, SelfHostedActio
|
||||
webVault: URL(string: state.webVaultServerUrl)?.sanitized
|
||||
)
|
||||
await delegate?.didSaveEnvironment(urls: urls)
|
||||
coordinator.navigate(to: .dismiss)
|
||||
coordinator.navigate(to: .dismissPresented)
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,7 +43,7 @@ class SelfHostedProcessorTests: BitwardenTestCase {
|
||||
delegate.savedUrls,
|
||||
EnvironmentUrlData(base: URL(string: "https://vault.bitwarden.com")!)
|
||||
)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismiss)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismissPresented)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.saveEnvironment` notifies the delegate that the user saved the URLs.
|
||||
@ -68,7 +68,7 @@ class SelfHostedProcessorTests: BitwardenTestCase {
|
||||
webVault: URL(string: "https://vault.bitwarden.com")!
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismiss)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismissPresented)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.saveEnvironment` displays an alert if any of the URLs are invalid.
|
||||
@ -98,7 +98,7 @@ class SelfHostedProcessorTests: BitwardenTestCase {
|
||||
func test_receive_dismiss() {
|
||||
subject.receive(.dismiss)
|
||||
|
||||
XCTAssertEqual(coordinator.routes.last, .dismiss)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismissPresented)
|
||||
}
|
||||
|
||||
/// Receiving `.iconsUrlChanged` updates the state.
|
||||
|
||||
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 144 KiB |
@ -0,0 +1,13 @@
|
||||
// MARK: - CheckEmailAction
|
||||
|
||||
/// Actions that can be processed by a `CheckEmailProcessor`.
|
||||
enum CheckEmailAction: Equatable {
|
||||
/// The dismiss button was tapped.
|
||||
case dismissTapped
|
||||
|
||||
/// The go back button was tapped
|
||||
case goBackTapped
|
||||
|
||||
/// The log in button was tapped
|
||||
case logInTapped
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
// MARK: - CheckEmailProcessor
|
||||
|
||||
/// The processor used to manage state and handle actions for the check email screen.
|
||||
///
|
||||
class CheckEmailProcessor: StateProcessor<CheckEmailState, CheckEmailAction, Void> {
|
||||
// MARK: Private Properties
|
||||
|
||||
/// The coordinator that handles navigation.
|
||||
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `CheckEmailProcessor`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - coordinator: The coordinator that handles navigation.
|
||||
/// - state: The initial state of the processor.
|
||||
///
|
||||
init(
|
||||
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
|
||||
state: CheckEmailState
|
||||
) {
|
||||
self.coordinator = coordinator
|
||||
super.init(state: state)
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
override func receive(_ action: CheckEmailAction) {
|
||||
switch action {
|
||||
case .dismissTapped,
|
||||
.logInTapped:
|
||||
coordinator.navigate(to: .dismiss)
|
||||
case .goBackTapped:
|
||||
coordinator.navigate(to: .dismissPresented)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import Networking
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
// MARK: - CheckEmailProcessorTests
|
||||
|
||||
class CheckEmailProcessorTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
|
||||
var subject: CheckEmailProcessor!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
|
||||
subject = CheckEmailProcessor(
|
||||
coordinator: coordinator.asAnyCoordinator(),
|
||||
state: CheckEmailState()
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
coordinator = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `receive(_:)` with `.dismiss` dismisses the view.
|
||||
func test_receive_dismissTapped() {
|
||||
subject.receive(.dismissTapped)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismiss)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.goBackTapped` dismisses the view.
|
||||
func test_receive_goBackTapped() {
|
||||
subject.receive(.goBackTapped)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismissPresented)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.logInTapped` dismisses the view.
|
||||
func test_receive_logInTapped() {
|
||||
subject.receive(.logInTapped)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismiss)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - CheckEmailState
|
||||
|
||||
/// An object that defines the current state of a `CheckEmailView`.
|
||||
///
|
||||
struct CheckEmailState: Equatable {
|
||||
// MARK: Properties
|
||||
|
||||
/// User's email address.
|
||||
var email: String = ""
|
||||
|
||||
// MARK: Computed Properties
|
||||
|
||||
/// Text with user email in bold
|
||||
var headelineTextBoldEmail: String {
|
||||
Localizations.followTheInstructionsInTheEmailSentToXToContinueCreatingYourAccount("**\(email)**")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - CheckEmailView
|
||||
|
||||
/// A view that allows the user to create an account.
|
||||
///
|
||||
struct CheckEmailView: View {
|
||||
// MARK: Properties
|
||||
|
||||
/// An object used to open urls from this view.
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
/// The store used to render the view.
|
||||
@ObservedObject var store: Store<CheckEmailState, CheckEmailAction, Void>
|
||||
|
||||
// MARK: View
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .center, spacing: 0) {
|
||||
Image(decorative: Asset.Images.checkEmail)
|
||||
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
|
||||
Text(Localizations.checkYourEmail)
|
||||
.styleGuide(.title2)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding([.bottom, .horizontal], 8)
|
||||
|
||||
Text(LocalizedStringKey(store.state.headelineTextBoldEmail))
|
||||
.styleGuide(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom, 20)
|
||||
.padding(.horizontal, 34)
|
||||
.tint(Asset.Colors.textPrimary.swiftUIColor)
|
||||
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
|
||||
|
||||
Button(Localizations.openEmailApp) {
|
||||
openURL(URL(string: "message://")!)
|
||||
}
|
||||
.accessibilityIdentifier("OpenEmailAppButton")
|
||||
.padding(.horizontal, 50)
|
||||
.padding(.bottom, 32)
|
||||
.buttonStyle(.primary())
|
||||
|
||||
Text(LocalizedStringKey(Localizations.noEmailGoBackToEditYourEmailAddress))
|
||||
.styleGuide(.subheadline)
|
||||
.tint(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
|
||||
.padding([.horizontal, .bottom], 32)
|
||||
.environment(\.openURL, OpenURLAction { _ in
|
||||
store.send(.goBackTapped)
|
||||
return .handled
|
||||
})
|
||||
|
||||
Text(LocalizedStringKey(Localizations.orLogInYouMayAlreadyHaveAnAccount))
|
||||
.styleGuide(.subheadline)
|
||||
.tint(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
|
||||
.padding(.horizontal, 32)
|
||||
.environment(\.openURL, OpenURLAction { _ in
|
||||
store.send(.logInTapped)
|
||||
return .handled
|
||||
})
|
||||
}
|
||||
}
|
||||
.navigationBar(title: Localizations.createAccount, titleDisplayMode: .inline)
|
||||
.scrollView()
|
||||
.toolbar {
|
||||
cancelToolbarItem {
|
||||
store.send(.dismissTapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Previews
|
||||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
CheckEmailView(store: Store(processor: StateProcessor(state: CheckEmailState(email: "email@example.com"))))
|
||||
}
|
||||
#endif
|
||||
@ -0,0 +1,46 @@
|
||||
import SnapshotTesting
|
||||
import SwiftUI
|
||||
import ViewInspector
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
// MARK: - CheckEmailViewTests
|
||||
|
||||
class CheckEmailViewTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var processor: MockProcessor<CheckEmailState, CheckEmailAction, Void>!
|
||||
var subject: CheckEmailView!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
processor = MockProcessor(state: CheckEmailState(email: "example@email.com"))
|
||||
let store = Store(processor: processor)
|
||||
subject = CheckEmailView(store: store)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
processor = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// Tapping the cancel button dispatches the `.dismiss` action.
|
||||
func test_cancelButton_tap() throws {
|
||||
let button = try subject.inspect().find(button: Localizations.cancel)
|
||||
try button.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .dismissTapped)
|
||||
}
|
||||
|
||||
// MARK: Snapshots
|
||||
|
||||
/// Tests the view renders correctly.
|
||||
func test_snapshot_empty() {
|
||||
assertSnapshots(matching: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 207 KiB |
@ -0,0 +1,20 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
extension StartRegistrationState {
|
||||
static func fixture(
|
||||
emailText: String = "example@email.com",
|
||||
isReceiveMarketingToggleOn: Bool = true,
|
||||
nameText: String = "name",
|
||||
region: RegionType = .unitedStates
|
||||
) -> Self {
|
||||
StartRegistrationState(
|
||||
emailText: emailText,
|
||||
isReceiveMarketingToggleOn: isReceiveMarketingToggleOn,
|
||||
nameText: nameText,
|
||||
region: region
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// MARK: - StartRegistrationAction
|
||||
|
||||
/// Actions that can be processed by a `StartRegistrationProcessor`.
|
||||
///
|
||||
enum StartRegistrationAction: Equatable {
|
||||
/// The `StartRegistrationView` was dismissed.
|
||||
case dismiss
|
||||
|
||||
/// The user edited the email text field.
|
||||
case emailTextChanged(String)
|
||||
|
||||
/// The user edited the name text field.
|
||||
case nameTextChanged(String)
|
||||
|
||||
/// The toast was shown or hidden.
|
||||
case toastShown(Toast?)
|
||||
|
||||
/// An action to toggle the terms and privacy agreement.
|
||||
case toggleReceiveMarketing(Bool)
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
// MARK: - StartRegistrationEffect
|
||||
|
||||
/// The enumeration of possible effects performed by the `StartRegistrationProcessor`.
|
||||
///
|
||||
enum StartRegistrationEffect: Equatable {
|
||||
/// The start registration appeared on screen.
|
||||
case appeared
|
||||
|
||||
/// The user tapped the region selector button
|
||||
case regionTapped
|
||||
|
||||
/// The user pressed `Submit` on the `StartRegistrationView`, attempting to create an account.
|
||||
case startRegistration
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
import AuthenticationServices
|
||||
import BitwardenSdk
|
||||
import Combine
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
// MARK: - StartRegistrationDelegate
|
||||
|
||||
/// A delegate of `StartRegistrationProcessor` that is notified when the user changes region.
|
||||
///
|
||||
protocol StartRegistrationDelegate: AnyObject {
|
||||
/// Called when the user changes regions.
|
||||
///
|
||||
func didChangeRegion() async
|
||||
}
|
||||
|
||||
// MARK: - StartRegistrationError
|
||||
|
||||
/// Enumeration of errors that may occur when start registration.
|
||||
///
|
||||
enum StartRegistrationError: Error {
|
||||
/// The terms of service and privacy policy have not been acknowledged.
|
||||
case acceptPoliciesError
|
||||
|
||||
/// The email field is empty.
|
||||
case emailEmpty
|
||||
|
||||
/// The email is invalid.
|
||||
case invalidEmail
|
||||
}
|
||||
|
||||
// MARK: - StartRegistrationProcessor
|
||||
|
||||
/// The processor used to manage state and handle actions for the start registration screen.
|
||||
///
|
||||
class StartRegistrationProcessor: StateProcessor<
|
||||
StartRegistrationState,
|
||||
StartRegistrationAction,
|
||||
StartRegistrationEffect
|
||||
> {
|
||||
// MARK: Types
|
||||
|
||||
typealias Services = HasAccountAPIService
|
||||
& HasAuthRepository
|
||||
& HasClientService
|
||||
& HasEnvironmentService
|
||||
& HasErrorReporter
|
||||
& HasStateService
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
/// The coordinator that handles navigation.
|
||||
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
|
||||
|
||||
/// The services used by the processor.
|
||||
private let services: Services
|
||||
|
||||
/// The delegate for the processor that is notified when the user closes the registration view.
|
||||
private weak var delegate: StartRegistrationDelegate?
|
||||
|
||||
/// Helper class with region specific functions
|
||||
private lazy var regionHelper = RegionHelper(
|
||||
coordinator: coordinator,
|
||||
delegate: self,
|
||||
stateService: services.stateService
|
||||
)
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `StartRegistrationProcessor`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - coordinator: The coordinator that handles navigation.
|
||||
/// - services: The services used by the processor.
|
||||
/// - state: The initial state of the processor.
|
||||
///
|
||||
init(
|
||||
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
|
||||
delegate: StartRegistrationDelegate?,
|
||||
services: Services,
|
||||
state: StartRegistrationState
|
||||
) {
|
||||
self.coordinator = coordinator
|
||||
self.delegate = delegate
|
||||
self.services = services
|
||||
super.init(state: state)
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
override func perform(_ effect: StartRegistrationEffect) async {
|
||||
switch effect {
|
||||
case .appeared:
|
||||
await regionHelper.loadRegion()
|
||||
state.isReceiveMarketingToggleOn = state.region == .unitedStates
|
||||
case .regionTapped:
|
||||
await regionHelper.presentRegionSelectorAlert(
|
||||
title: Localizations.creatingOn,
|
||||
currentRegion: state.region
|
||||
)
|
||||
case .startRegistration:
|
||||
await startRegistration()
|
||||
}
|
||||
}
|
||||
|
||||
override func receive(_ action: StartRegistrationAction) {
|
||||
switch action {
|
||||
case let .emailTextChanged(text):
|
||||
state.emailText = text
|
||||
case .dismiss:
|
||||
coordinator.navigate(to: .dismiss)
|
||||
case let .nameTextChanged(text):
|
||||
state.nameText = text
|
||||
case let .toggleReceiveMarketing(newValue):
|
||||
state.isReceiveMarketingToggleOn = newValue
|
||||
case let .toastShown(toast):
|
||||
state.toast = toast
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private methods
|
||||
|
||||
/// Initiates the first step of the registration.
|
||||
///
|
||||
private func startRegistration() async {
|
||||
// Hide the loading overlay when exiting this method, in case it hasn't been hidden yet.
|
||||
defer { coordinator.hideLoadingOverlay() }
|
||||
|
||||
do {
|
||||
let email = state.emailText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let name = state.nameText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !email.isEmpty else {
|
||||
throw StartRegistrationError.emailEmpty
|
||||
}
|
||||
|
||||
guard email.isValidEmail else {
|
||||
throw StartRegistrationError.invalidEmail
|
||||
}
|
||||
|
||||
coordinator.showLoadingOverlay(title: Localizations.creatingAccount)
|
||||
|
||||
let result = try await services.accountAPIService.startRegistration(
|
||||
requestModel: StartRegistrationRequestModel(
|
||||
email: email,
|
||||
name: name,
|
||||
receiveMarketingEmails: state.isReceiveMarketingToggleOn
|
||||
)
|
||||
)
|
||||
|
||||
if let token = result.token,
|
||||
!token.isEmpty {
|
||||
coordinator.navigate(to: .completeRegistration(
|
||||
emailVerificationToken: token,
|
||||
userEmail: state.emailText
|
||||
))
|
||||
} else {
|
||||
coordinator.navigate(to: .checkEmail(email: state.emailText))
|
||||
}
|
||||
} catch let error as StartRegistrationError {
|
||||
showStartRegistrationErrorAlert(error)
|
||||
} catch {
|
||||
coordinator.showAlert(.networkResponseError(error) {
|
||||
await self.startRegistration()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a `StartRegistrationError` alert.
|
||||
///
|
||||
/// - Parameter error: The error that occurred.
|
||||
///
|
||||
private func showStartRegistrationErrorAlert(_ error: StartRegistrationError) {
|
||||
switch error {
|
||||
case .acceptPoliciesError:
|
||||
coordinator.showAlert(.acceptPoliciesAlert())
|
||||
case .emailEmpty:
|
||||
coordinator.showAlert(.validationFieldRequired(fieldName: Localizations.email))
|
||||
case .invalidEmail:
|
||||
coordinator.showAlert(.invalidEmail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SelfHostedProcessorDelegate
|
||||
|
||||
extension StartRegistrationProcessor: SelfHostedProcessorDelegate {
|
||||
func didSaveEnvironment(urls: EnvironmentUrlData) async {
|
||||
await setRegion(.selfHosted, urls)
|
||||
state.toast = Toast(text: Localizations.environmentSaved)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RegionDelegate
|
||||
|
||||
extension StartRegistrationProcessor: RegionDelegate {
|
||||
/// Sets the region and the URLs to use.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - region: The region to use.
|
||||
/// - urls: The URLs that the app should use for the region.
|
||||
///
|
||||
func setRegion(_ region: RegionType, _ urls: EnvironmentUrlData) async {
|
||||
guard !urls.isEmpty else { return }
|
||||
await services.environmentService.setPreAuthURLs(urls: urls)
|
||||
state.region = region
|
||||
state.showReceiveMarketingToggle = state.region != .selfHosted
|
||||
await delegate?.didChangeRegion()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,466 @@
|
||||
import AuthenticationServices
|
||||
import Networking
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
// MARK: - StartRegistrationProcessorTests
|
||||
|
||||
class StartRegistrationProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
|
||||
// MARK: Properties
|
||||
|
||||
var authRepository: MockAuthRepository!
|
||||
var captchaService: MockCaptchaService!
|
||||
var client: MockHTTPClient!
|
||||
var clientAuth: MockClientAuth!
|
||||
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var subject: StartRegistrationProcessor!
|
||||
var delegate: MockStartRegistrationDelegate!
|
||||
var stateService: MockStateService!
|
||||
var environmentService: MockEnvironmentService!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
authRepository = MockAuthRepository()
|
||||
captchaService = MockCaptchaService()
|
||||
client = MockHTTPClient()
|
||||
clientAuth = MockClientAuth()
|
||||
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
|
||||
environmentService = MockEnvironmentService()
|
||||
errorReporter = MockErrorReporter()
|
||||
stateService = MockStateService()
|
||||
|
||||
subject = StartRegistrationProcessor(
|
||||
coordinator: coordinator.asAnyCoordinator(),
|
||||
delegate: delegate,
|
||||
services: ServiceContainer.withMocks(
|
||||
authRepository: authRepository,
|
||||
captchaService: captchaService,
|
||||
clientService: MockClientService(auth: clientAuth),
|
||||
environmentService: environmentService,
|
||||
errorReporter: errorReporter,
|
||||
httpClient: client,
|
||||
stateService: stateService
|
||||
),
|
||||
state: StartRegistrationState()
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
authRepository = nil
|
||||
captchaService = nil
|
||||
clientAuth = nil
|
||||
client = nil
|
||||
coordinator = nil
|
||||
environmentService = nil
|
||||
errorReporter = nil
|
||||
subject = nil
|
||||
stateService = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `perform(_:)` with `.regionTapped` navigates to the region selection screen.
|
||||
func test_perform_regionTapped() async throws {
|
||||
await subject.perform(.regionTapped)
|
||||
|
||||
var alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.title, Localizations.creatingOn)
|
||||
XCTAssertNil(alert.message)
|
||||
XCTAssertEqual(alert.alertActions.count, 4)
|
||||
|
||||
XCTAssertEqual(alert.alertActions[0].title, "bitwarden.com")
|
||||
try await alert.tapAction(title: "bitwarden.com")
|
||||
XCTAssertEqual(subject.state.region, .unitedStates)
|
||||
|
||||
await subject.perform(.regionTapped)
|
||||
alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.alertActions[1].title, "bitwarden.eu")
|
||||
try await alert.tapAction(title: "bitwarden.eu")
|
||||
XCTAssertEqual(subject.state.region, .europe)
|
||||
|
||||
await subject.perform(.regionTapped)
|
||||
alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.alertActions[2].title, Localizations.selfHosted)
|
||||
try await alert.tapAction(title: Localizations.selfHosted)
|
||||
XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .europe))
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.startRegistration` presents an alert when the email has already been taken.
|
||||
func test_perform_startRegistration_emailExists() async {
|
||||
subject.state = .fixture()
|
||||
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: APITestData.startRegistrationEmailAlreadyExists.data
|
||||
)
|
||||
|
||||
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
|
||||
|
||||
client.result = .httpFailure(
|
||||
ServerError.error(errorResponse: errorResponse)
|
||||
)
|
||||
|
||||
await subject.perform(.startRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertEqual(
|
||||
coordinator.alertShown.last,
|
||||
.defaultAlert(
|
||||
title: Localizations.anErrorHasOccurred,
|
||||
message: "Email 'j@a.com' is already taken."
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.startRegistration` presents an alert when the email exceeds the maximum length.
|
||||
func test_perform_startRegistration_emailExceedsMaxLength() async {
|
||||
subject.state = .fixture(emailText: """
|
||||
eyrztwlvxqdksnmcbjgahfpouyqiwubfdzoxhjsrlnvgeatkcpimy\
|
||||
fqaxhztsowbmdkjlrpnuqvycigfexrvlosqtpnheujawzsdmkbfoy\
|
||||
cxqpwkzthbnmudxlysgarcejfqvopzrkihwdelbuxyfqnjsgptamcozrvihsl\
|
||||
nbujrtdosmvhxwyfapzcklqoxbgdvtfieqyuhwajnrpslmcskgzofdqehxcbv\
|
||||
omjltzafwudqypnisgrkeohycbvxjflaumtwzrdqnpsoiezgyhqbmxdlvnzwa\
|
||||
htjoekrcispgvyfbuqklszepjwdrantihxfcoygmuslqbajzdfgrkmwbpnouq\
|
||||
tlsvixechyfjslrdvngiwzqpcotxubamhyekufjrzdwmxihqkfonslbcjgtpu\
|
||||
voyaezrctudwlskjpvmfqhnxbriyg@example.com
|
||||
""")
|
||||
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: APITestData.startRegistrationEmailExceedsMaxLength.data
|
||||
)
|
||||
|
||||
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
|
||||
|
||||
client.result = .httpFailure(
|
||||
ServerError.error(errorResponse: errorResponse)
|
||||
)
|
||||
|
||||
await subject.perform(.startRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertEqual(
|
||||
coordinator.alertShown.last,
|
||||
.defaultAlert(
|
||||
title: Localizations.anErrorHasOccurred,
|
||||
message: "The field Email must be a string with a maximum length of 256."
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.startRegistration` presents an alert when the email field is empty.
|
||||
func test_perform_startRegistration_emptyEmail() async {
|
||||
subject.state = .fixture(emailText: "")
|
||||
|
||||
client.result = .httpSuccess(testData: .startRegistrationSuccess)
|
||||
|
||||
await subject.perform(.startRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 0)
|
||||
XCTAssertEqual(coordinator.alertShown.last, .validationFieldRequired(fieldName: "Email"))
|
||||
XCTAssertTrue(coordinator.loadingOverlaysShown.isEmpty)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.startRegistration` presents an alert when the email is in an invalid format.
|
||||
func test_perform_startRegistration_invalidEmailFormat() async {
|
||||
subject.state = .fixture(emailText: "∫@ø.com")
|
||||
|
||||
let response = HTTPResponse.failure(
|
||||
statusCode: 400,
|
||||
body: APITestData.startRegistrationInvalidEmailFormat.data
|
||||
)
|
||||
|
||||
guard let errorResponse = try? ErrorResponseModel(response: response) else { return }
|
||||
|
||||
client.result = .httpFailure(
|
||||
ServerError.error(errorResponse: errorResponse)
|
||||
)
|
||||
|
||||
await subject.perform(.startRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertEqual(
|
||||
coordinator.alertShown.last,
|
||||
.defaultAlert(
|
||||
title: Localizations.anErrorHasOccurred,
|
||||
message: "The Email field is not a supported e-mail address format."
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.startRegistration` presents an alert when there is no internet connection.
|
||||
/// When the user taps `Try again`, the create account request is made again.
|
||||
func test_perform_startRegistration_noInternetConnection() async throws {
|
||||
subject.state = .fixture()
|
||||
|
||||
let urlError = URLError(.notConnectedToInternet) as Error
|
||||
client.results = [.httpFailure(urlError), .httpSuccess(testData: .startRegistrationSuccess)]
|
||||
|
||||
await subject.perform(.startRegistration)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert, Alert.networkResponseError(urlError) {
|
||||
await self.subject.perform(.startRegistration)
|
||||
})
|
||||
|
||||
try await alert.tapAction(title: Localizations.tryAgain)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 2)
|
||||
XCTAssertEqual(
|
||||
client.requests[0].url,
|
||||
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
client.requests[1].url,
|
||||
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
|
||||
)
|
||||
XCTAssertEqual(coordinator.routes.last, .completeRegistration(
|
||||
emailVerificationToken: "0018A45C4D1DEF81644B54AB7F969B88D65\n",
|
||||
userEmail: "example@email.com"
|
||||
))
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(
|
||||
coordinator.loadingOverlaysShown,
|
||||
[
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.startRegistration` presents an alert when the request times out.
|
||||
/// When the user taps `Try again`, the create account request is made again.
|
||||
func test_perform_startRegistration_timeout() async throws {
|
||||
subject.state = .fixture()
|
||||
|
||||
let urlError = URLError(.timedOut) as Error
|
||||
client.results = [.httpFailure(urlError), .httpSuccess(testData: .startRegistrationSuccess)]
|
||||
|
||||
await subject.perform(.startRegistration)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.message, urlError.localizedDescription)
|
||||
|
||||
try await alert.tapAction(title: Localizations.tryAgain)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 2)
|
||||
XCTAssertEqual(
|
||||
client.requests[0].url,
|
||||
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
client.requests[1].url,
|
||||
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
|
||||
)
|
||||
XCTAssertEqual(coordinator.routes.last, .completeRegistration(
|
||||
emailVerificationToken: "0018A45C4D1DEF81644B54AB7F969B88D65\n",
|
||||
userEmail: "example@email.com"
|
||||
))
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(
|
||||
coordinator.loadingOverlaysShown,
|
||||
[
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
LoadingOverlayState(title: Localizations.creatingAccount),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.startRegistration` and an invalid email navigates to an invalid email alert.
|
||||
func test_perform_startRegistration_withInvalidEmail() async {
|
||||
subject.state = .fixture(emailText: "exampleemail.com")
|
||||
|
||||
client.result = .httpFailure(StartRegistrationError.invalidEmail)
|
||||
|
||||
await subject.perform(.startRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 0)
|
||||
XCTAssertEqual(coordinator.alertShown.last, .invalidEmail)
|
||||
XCTAssertTrue(coordinator.loadingOverlaysShown.isEmpty)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.startRegistration` and a valid email creates the user's account.
|
||||
func test_perform_startRegistration_withValidEmail() async {
|
||||
subject.state = .fixture()
|
||||
|
||||
client.result = .httpSuccess(testData: .startRegistrationSuccess)
|
||||
|
||||
await subject.perform(.startRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertEqual(
|
||||
client.requests[0].url,
|
||||
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
|
||||
)
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.startRegistration` and a valid email surrounded by whitespace trims the whitespace and
|
||||
/// creates the user's account
|
||||
func test_perform_startRegistration_withValidEmailAndSpace() async {
|
||||
subject.state = .fixture(emailText: " email@example.com ")
|
||||
|
||||
client.result = .httpSuccess(testData: .startRegistrationSuccess)
|
||||
|
||||
await subject.perform(.startRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertEqual(
|
||||
client.requests[0].url,
|
||||
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
|
||||
)
|
||||
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.startRegistration` and a valid email with uppercase characters
|
||||
/// converts the email to lowercase
|
||||
/// and creates the user's account.
|
||||
func test_perform_startRegistration_withValidEmailUppercased() async {
|
||||
subject.state = .fixture(emailText: "EMAIL@EXAMPLE.COM")
|
||||
|
||||
client.result = .httpSuccess(testData: .startRegistrationSuccess)
|
||||
|
||||
await subject.perform(.startRegistration)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 1)
|
||||
XCTAssertEqual(
|
||||
client.requests[0].url,
|
||||
URL(string: "https://example.com/identity/accounts/register/send-verification-email")
|
||||
)
|
||||
XCTAssertFalse(coordinator.isLoadingOverlayShowing)
|
||||
XCTAssertEqual(coordinator.loadingOverlaysShown, [LoadingOverlayState(title: Localizations.creatingAccount)])
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.dismiss` dismisses the view.
|
||||
func test_receive_dismiss() {
|
||||
subject.receive(.dismiss)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismiss)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.emailTextChanged(_:)` updates the state to reflect the change.
|
||||
func test_receive_emailTextChanged() {
|
||||
subject.state.emailText = ""
|
||||
XCTAssertTrue(subject.state.emailText.isEmpty)
|
||||
|
||||
subject.receive(.emailTextChanged("updated email"))
|
||||
XCTAssertTrue(subject.state.emailText == "updated email")
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.toggleReceiveMarketing(_:)` updates the state to reflect the change.
|
||||
func test_receive_toggleTermsAndPrivacy() {
|
||||
subject.receive(.toggleReceiveMarketing(false))
|
||||
XCTAssertFalse(subject.state.isReceiveMarketingToggleOn)
|
||||
|
||||
subject.receive(.toggleReceiveMarketing(true))
|
||||
XCTAssertTrue(subject.state.isReceiveMarketingToggleOn)
|
||||
|
||||
subject.receive(.toggleReceiveMarketing(true))
|
||||
XCTAssertTrue(subject.state.isReceiveMarketingToggleOn)
|
||||
}
|
||||
|
||||
/// `didSaveEnvironment(urls:)` with URLs sets the region to self-hosted and sets the URLs in
|
||||
/// the environment.
|
||||
func test_didSaveEnvironment() async {
|
||||
subject.state.region = .unitedStates
|
||||
await subject.didSaveEnvironment(urls: EnvironmentUrlData(base: .example))
|
||||
XCTAssertEqual(subject.state.region, .selfHosted)
|
||||
XCTAssertEqual(subject.state.toast?.text, Localizations.environmentSaved)
|
||||
XCTAssertEqual(
|
||||
environmentService.setPreAuthEnvironmentUrlsData,
|
||||
EnvironmentUrlData(base: .example)
|
||||
)
|
||||
}
|
||||
|
||||
/// `didSaveEnvironment(urls:)` with empty URLs doesn't change the region or the environment URLs.
|
||||
func test_didSaveEnvironment_empty() async {
|
||||
subject.state.region = .unitedStates
|
||||
await subject.didSaveEnvironment(urls: EnvironmentUrlData())
|
||||
XCTAssertEqual(subject.state.region, .unitedStates)
|
||||
XCTAssertNil(environmentService.setPreAuthEnvironmentUrlsData)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with no pre-auth URLs defaults the region and URLs to the US environment.
|
||||
func test_perform_appeared_loadsRegion_noPreAuthUrls() async {
|
||||
await subject.perform(.appeared)
|
||||
XCTAssertEqual(subject.state.region, .unitedStates)
|
||||
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultUS)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with EU pre-auth URLs sets the state to the EU region and sets the
|
||||
/// environment URLs.
|
||||
func test_perform_appeared_loadsRegion_withPreAuthUrls_europe() async {
|
||||
stateService.preAuthEnvironmentUrls = .defaultEU
|
||||
await subject.perform(.appeared)
|
||||
XCTAssertEqual(subject.state.region, .europe)
|
||||
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultEU)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with self-hosted pre-auth URLs sets the state to the self-hosted region
|
||||
/// and sets the URLs to the environment.
|
||||
func test_perform_appeared_loadsRegion_withPreAuthUrls_selfHosted() async {
|
||||
let urls = EnvironmentUrlData(base: .example)
|
||||
stateService.preAuthEnvironmentUrls = urls
|
||||
await subject.perform(.appeared)
|
||||
XCTAssertEqual(subject.state.region, .selfHosted)
|
||||
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, urls)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with US pre-auth URLs sets the state to the US region and sets the
|
||||
/// environment URLs.
|
||||
func test_perform_appeared_loadsRegion_withPreAuthUrls_unitedStates() async {
|
||||
stateService.preAuthEnvironmentUrls = .defaultUS
|
||||
await subject.perform(.appeared)
|
||||
XCTAssertEqual(subject.state.region, .unitedStates)
|
||||
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultUS)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with US pre-auth URLs sets the state to the US region and sets the
|
||||
/// environment URLs.
|
||||
/// Test if isReceiveMarketingToggle is On
|
||||
func test_perform_appeared_loadsRegion_withPreAuthUrls_unitedStates_isReceiveMarketingToggle_on() async {
|
||||
stateService.preAuthEnvironmentUrls = .defaultUS
|
||||
await subject.perform(.appeared)
|
||||
XCTAssertEqual(subject.state.region, .unitedStates)
|
||||
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultUS)
|
||||
XCTAssertTrue(subject.state.isReceiveMarketingToggleOn)
|
||||
}
|
||||
|
||||
/// `perform(.appeared)` with EU pre-auth URLs sets the state to the EU region and sets the
|
||||
/// environment URLs.
|
||||
/// Test if isReceiveMarketingToggle is Off
|
||||
func test_perform_appeared_loadsRegion_withPreAuthUrls_europe_isReceiveMarketingToggle_off() async {
|
||||
stateService.preAuthEnvironmentUrls = .defaultEU
|
||||
await subject.perform(.appeared)
|
||||
XCTAssertEqual(subject.state.region, .europe)
|
||||
XCTAssertEqual(environmentService.setPreAuthEnvironmentUrlsData, .defaultEU)
|
||||
XCTAssertFalse(subject.state.isReceiveMarketingToggleOn)
|
||||
}
|
||||
}
|
||||
|
||||
class MockStartRegistrationDelegate: StartRegistrationDelegate {
|
||||
var didChangeRegionCalled: Bool = false
|
||||
|
||||
func didChangeRegion() async {
|
||||
didChangeRegionCalled = true
|
||||
}
|
||||
} // swiftlint:disable:this file_length
|
||||
@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - StartRegistrationState
|
||||
|
||||
/// An object that defines the current state of a `StartRegistrationView`.
|
||||
///
|
||||
struct StartRegistrationState: Equatable {
|
||||
// MARK: Properties
|
||||
|
||||
/// The text in the email text field.
|
||||
var emailText: String = ""
|
||||
|
||||
/// Whether the terms and privacy toggle is on.
|
||||
var isReceiveMarketingToggleOn: Bool = false
|
||||
|
||||
/// The text in the name text field.
|
||||
var nameText: String = ""
|
||||
|
||||
/// The text in the receive marketing emails toggle
|
||||
var receiveMarketingEmailsText: String {
|
||||
Localizations.getAdviceAnnouncementsAndResearchOpportunitiesFromBitwardenInYourInboxUnsubscribeAtAnyTime(
|
||||
ExternalLinksConstants.unsubscribeFromMarketingEmails
|
||||
)
|
||||
}
|
||||
|
||||
/// The region selected by the user.
|
||||
var region: RegionType = .europe
|
||||
|
||||
/// The value which determines if the toggle is shown
|
||||
var showReceiveMarketingToggle = true
|
||||
|
||||
/// Terms and privacy disclaimer text
|
||||
var termsAndPrivacyDisclaimerText: String {
|
||||
Localizations.byContinuingYouAgreeToTheTermsOfServiceAndPrivacyPolicy(
|
||||
ExternalLinksConstants.termsOfService,
|
||||
ExternalLinksConstants.privacyPolicy
|
||||
)
|
||||
}
|
||||
|
||||
/// A toast message to show in the view.
|
||||
var toast: Toast?
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - StartRegistrationView
|
||||
|
||||
/// A view that allows the user to create an account.
|
||||
///
|
||||
struct StartRegistrationView: View {
|
||||
// MARK: Properties
|
||||
|
||||
/// An action that opens URLs.
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
/// The store used to render the view.
|
||||
@ObservedObject var store: Store<StartRegistrationState, StartRegistrationAction, StartRegistrationEffect>
|
||||
|
||||
// MARK: View
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
email
|
||||
.padding(.bottom, 8)
|
||||
|
||||
RegionSelector(
|
||||
selectorLabel: Localizations.creatingOn,
|
||||
regionName: store.state.region.baseUrlDescription
|
||||
) {
|
||||
await store.perform(.regionTapped)
|
||||
}
|
||||
}
|
||||
|
||||
name
|
||||
|
||||
receiveMarketingToggle
|
||||
|
||||
continueButton
|
||||
|
||||
termsAndPrivacyText
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.navigationBar(title: Localizations.createAccount, titleDisplayMode: .inline)
|
||||
.scrollView()
|
||||
.task {
|
||||
await store.perform(.appeared)
|
||||
}
|
||||
.toolbar {
|
||||
cancelToolbarItem {
|
||||
store.send(.dismiss)
|
||||
}
|
||||
}
|
||||
.toast(store.binding(
|
||||
get: \.toast,
|
||||
send: StartRegistrationAction.toastShown
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: Private views
|
||||
|
||||
/// The text fields for the user's email and password.
|
||||
private var email: some View {
|
||||
BitwardenTextField(
|
||||
title: Localizations.emailAddress,
|
||||
text: store.binding(
|
||||
get: \.emailText,
|
||||
send: StartRegistrationAction.emailTextChanged
|
||||
),
|
||||
accessibilityIdentifier: "EmailAddressEntry"
|
||||
)
|
||||
.textFieldConfiguration(.email)
|
||||
}
|
||||
|
||||
/// The text fields for the user's email and password.
|
||||
private var name: some View {
|
||||
BitwardenTextField(
|
||||
title: Localizations.name,
|
||||
text: store.binding(
|
||||
get: \.nameText,
|
||||
send: StartRegistrationAction.nameTextChanged
|
||||
),
|
||||
accessibilityIdentifier: "nameEntry"
|
||||
)
|
||||
.textFieldConfiguration(.username)
|
||||
}
|
||||
|
||||
/// The button pressed when the user attempts to create the account.
|
||||
private var continueButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await store.perform(.startRegistration)
|
||||
}
|
||||
} label: {
|
||||
Text(Localizations.continue)
|
||||
}
|
||||
.accessibilityIdentifier("ContinueButton")
|
||||
.buttonStyle(.primary())
|
||||
}
|
||||
|
||||
/// The button pressed when the user attempts to create the account.
|
||||
private var termsAndPrivacyText: some View {
|
||||
Text(LocalizedStringKey(store.state.termsAndPrivacyDisclaimerText))
|
||||
.styleGuide(.footnote)
|
||||
.tint(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
|
||||
.padding([.bottom], 32)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
/// A toggle for the terms and privacy agreement.
|
||||
@ViewBuilder private var receiveMarketingToggle: some View {
|
||||
if store.state.showReceiveMarketingToggle {
|
||||
Toggle(isOn: store.binding(
|
||||
get: \.isReceiveMarketingToggleOn,
|
||||
send: StartRegistrationAction.toggleReceiveMarketing
|
||||
)) {
|
||||
Text(LocalizedStringKey(store.state.receiveMarketingEmailsText))
|
||||
.tint(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
|
||||
.styleGuide(.subheadline)
|
||||
}
|
||||
.accessibilityIdentifier("ReceiveMarketingToggle")
|
||||
.foregroundColor(Color(asset: Asset.Colors.textPrimary))
|
||||
.toggleStyle(.bitwarden)
|
||||
.id(ViewIdentifier.StartRegistration.receiveMarketing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Previews
|
||||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
StartRegistrationView(store: Store(processor: StateProcessor(state: StartRegistrationState())))
|
||||
}
|
||||
#endif
|
||||
@ -0,0 +1,120 @@
|
||||
import SnapshotTesting
|
||||
import SwiftUI
|
||||
import ViewInspector
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
// MARK: - StartRegistrationViewTests
|
||||
|
||||
class StartRegistrationViewTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var processor: MockProcessor<StartRegistrationState, StartRegistrationAction, StartRegistrationEffect>!
|
||||
var subject: StartRegistrationView!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
processor = MockProcessor(state: StartRegistrationState())
|
||||
let store = Store(processor: processor)
|
||||
subject = StartRegistrationView(store: store)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
processor = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// Tapping the cancel button dispatches the `.dismiss` action.
|
||||
func test_cancelButton_tap() throws {
|
||||
let button = try subject.inspect().find(button: Localizations.cancel)
|
||||
try button.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .dismiss)
|
||||
}
|
||||
|
||||
/// Updating the text field dispatches the `.emailTextChanged()` action.
|
||||
func test_emailField_updateValue() throws {
|
||||
let textfield = try subject.inspect().find(viewWithId: Localizations.emailAddress).textField()
|
||||
try textfield.setInput("text")
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .emailTextChanged("text"))
|
||||
}
|
||||
|
||||
/// Updating the text field dispatches the `.nameTextChanged()` action.
|
||||
func test_nameField_updateValue() throws {
|
||||
let textfield = try subject.inspect().find(viewWithId: Localizations.name).textField()
|
||||
try textfield.setInput("user name")
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .nameTextChanged("user name"))
|
||||
}
|
||||
|
||||
/// Tapping the continue button performs the `.StartRegistration` effect.
|
||||
func test_continueButton_tap() throws {
|
||||
let button = try subject.inspect().find(button: Localizations.continue)
|
||||
try button.tap()
|
||||
|
||||
waitFor(!processor.effects.isEmpty)
|
||||
|
||||
XCTAssertEqual(processor.effects.last, .startRegistration)
|
||||
}
|
||||
|
||||
/// Tapping the region button dispatches the `.regionPressed` action.
|
||||
func test_regionButton_tap() throws {
|
||||
let button = try subject.inspect().find(
|
||||
button: "\(Localizations.creatingOn): \(subject.store.state.region.baseUrlDescription)"
|
||||
)
|
||||
try button.tap()
|
||||
waitFor(!processor.effects.isEmpty)
|
||||
XCTAssertEqual(processor.effects.last, .regionTapped)
|
||||
}
|
||||
|
||||
/// Tapping the receive marketing toggle dispatches the `.toggleReceiveMarketing()` action.
|
||||
func test_receiveMarketingToggle_tap() throws {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
throw XCTSkip("Unable to run test in iOS 16, keep an eye on ViewInspector to see if it gets updated.")
|
||||
}
|
||||
let toggle = try subject.inspect().find(viewWithId: ViewIdentifier.StartRegistration.receiveMarketing).toggle()
|
||||
try toggle.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .toggleReceiveMarketing(true))
|
||||
}
|
||||
|
||||
// MARK: Snapshots
|
||||
|
||||
/// Tests the view renders correctly when the text fields are all empty.
|
||||
func test_snapshot_empty() {
|
||||
assertSnapshot(matching: subject, as: .defaultPortrait)
|
||||
}
|
||||
|
||||
/// Tests the view renders correctly when the text fields are all populated.
|
||||
func test_snapshot_textFields_populated() throws {
|
||||
processor.state.emailText = "email@example.com"
|
||||
processor.state.nameText = "user name"
|
||||
|
||||
assertSnapshots(matching: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
|
||||
}
|
||||
|
||||
/// Tests the view renders correctly when the text fields are all populated with long text.
|
||||
func test_snapshot_textFields_populated_long() throws {
|
||||
processor.state.emailText = "emailmmmmmmmmmmmmmmmmmmmmm@exammmmmmmmmmmmmmmmmmmmmmmmmmmmmmmple.com"
|
||||
processor.state.nameText = "user name name name name name name name name name name name name name name"
|
||||
|
||||
assertSnapshots(matching: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
|
||||
}
|
||||
|
||||
/// Tests the view renders correctly when the toggles are on.
|
||||
func test_snapshot_toggles_on() throws {
|
||||
processor.state.isReceiveMarketingToggleOn = true
|
||||
|
||||
assertSnapshot(matching: subject, as: .defaultPortrait)
|
||||
}
|
||||
|
||||
/// Tests the view renders correctly when the marketing toggle is hidden.
|
||||
func test_snapshot_marketingToggle_hidden() throws {
|
||||
processor.state.showReceiveMarketingToggle = false
|
||||
|
||||
assertSnapshot(matching: subject, as: .defaultPortrait)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 137 KiB |
89
BitwardenShared/UI/Auth/Utilities/RegionHelper.swift
Normal file
@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - RegionHelper
|
||||
|
||||
/// Helper class with common functionality related to the region selector.
|
||||
///
|
||||
class RegionHelper {
|
||||
/// Used to perform navigations and showing alert
|
||||
let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
|
||||
|
||||
/// Service used to get environment information
|
||||
let stateService: StateService
|
||||
|
||||
/// The delegate for the processor that is notified when the user saves their environment settings.
|
||||
weak var delegate: RegionDelegate?
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `RegionHelper`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - coordinator: The coordinator that handles navigation.
|
||||
/// - delegate: The delegate for the processor.
|
||||
/// - stateService: The services used by the helper .
|
||||
///
|
||||
init(coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
|
||||
delegate: RegionDelegate,
|
||||
stateService: StateService) {
|
||||
self.coordinator = coordinator
|
||||
self.delegate = delegate
|
||||
self.stateService = stateService
|
||||
}
|
||||
|
||||
/// Builds an alert for region selection and navigates to the alert.
|
||||
///
|
||||
func presentRegionSelectorAlert(title: String, currentRegion: RegionType?) async {
|
||||
let actions = RegionType.allCases.map { region in
|
||||
AlertAction(title: region.baseUrlDescription, style: .default) { _ in
|
||||
if let urls = region.defaultURLs {
|
||||
await self.delegate?.setRegion(region, urls)
|
||||
} else {
|
||||
await self.coordinator.navigate(
|
||||
to: .selfHosted(currentRegion: currentRegion ?? .unitedStates),
|
||||
context: self.delegate
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
let cancelAction = AlertAction(title: Localizations.cancel, style: .cancel)
|
||||
let alert = Alert(
|
||||
title: title,
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet,
|
||||
alertActions: actions + [cancelAction]
|
||||
)
|
||||
await coordinator.showAlert(alert)
|
||||
}
|
||||
|
||||
/// Sets the region to the last used region.
|
||||
///
|
||||
func loadRegion() async {
|
||||
guard let urls = await stateService.getPreAuthEnvironmentUrls() else {
|
||||
await delegate?.setRegion(.unitedStates, .defaultUS)
|
||||
return
|
||||
}
|
||||
|
||||
if urls.base == EnvironmentUrlData.defaultUS.base {
|
||||
await delegate?.setRegion(.unitedStates, urls)
|
||||
} else if urls.base == EnvironmentUrlData.defaultEU.base {
|
||||
await delegate?.setRegion(.europe, urls)
|
||||
} else {
|
||||
await delegate?.setRegion(.selfHosted, urls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RegionDelegate
|
||||
|
||||
/// A delegate of `Region` that is notified when the user saves their environment settings.
|
||||
///
|
||||
protocol RegionDelegate: AnyObject {
|
||||
/// Sets the region and the URLs to use.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - region: The region to use.
|
||||
/// - urls: The URLs that the app should use for the region.
|
||||
///
|
||||
func setRegion(_ region: RegionType, _ urls: EnvironmentUrlData) async
|
||||
}
|
||||
146
BitwardenShared/UI/Auth/Utilities/RegionHelperTests.swift
Normal file
@ -0,0 +1,146 @@
|
||||
import BitwardenSdk
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
// MARK: - RegionHelperTests
|
||||
|
||||
class RegionHelperTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var subject: RegionHelper!
|
||||
var regionDelegate: MockRegionDelegate!
|
||||
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
|
||||
var stateService: MockStateService!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
coordinator = MockCoordinator<AuthRoute, AuthEvent>()
|
||||
stateService = MockStateService()
|
||||
regionDelegate = MockRegionDelegate()
|
||||
|
||||
subject = RegionHelper(
|
||||
coordinator: coordinator.asAnyCoordinator(),
|
||||
delegate: regionDelegate,
|
||||
stateService: stateService
|
||||
)
|
||||
subject.delegate = regionDelegate
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `presentRegionSelectorAlert(title:currentRegion)` shows alert and tap bitwarden.com.
|
||||
func test_presentRegionSelectorAlert_us() async throws {
|
||||
await subject.presentRegionSelectorAlert(title: Localizations.creatingOn, currentRegion: .unitedStates)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.title, Localizations.creatingOn)
|
||||
XCTAssertNil(alert.message)
|
||||
XCTAssertEqual(alert.alertActions.count, 4)
|
||||
|
||||
XCTAssertEqual(alert.alertActions[0].title, "bitwarden.com")
|
||||
try await alert.tapAction(title: "bitwarden.com")
|
||||
XCTAssertTrue(regionDelegate.setRegionCalled)
|
||||
XCTAssertEqual(regionDelegate.setRegionType, .unitedStates)
|
||||
XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.unitedStates.defaultURLs)
|
||||
}
|
||||
|
||||
/// `presentRegionSelectorAlert(title:currentRegion)` shows alert and tap bitwarden.eu.
|
||||
func test_presentRegionSelectorAlert_eu() async throws {
|
||||
await subject.presentRegionSelectorAlert(title: Localizations.creatingOn, currentRegion: .unitedStates)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.title, Localizations.creatingOn)
|
||||
XCTAssertNil(alert.message)
|
||||
XCTAssertEqual(alert.alertActions.count, 4)
|
||||
|
||||
XCTAssertEqual(alert.alertActions[1].title, "bitwarden.eu")
|
||||
try await alert.tapAction(title: "bitwarden.eu")
|
||||
XCTAssertTrue(regionDelegate.setRegionCalled)
|
||||
XCTAssertEqual(regionDelegate.setRegionType, .europe)
|
||||
XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.europe.defaultURLs)
|
||||
}
|
||||
|
||||
/// `presentRegionSelectorAlert(title:currentRegion)` shows alert and tap selfhosted.
|
||||
func test_presentRegionSelectorAlert_selfHosted() async throws {
|
||||
await subject.presentRegionSelectorAlert(title: Localizations.creatingOn, currentRegion: .europe)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.title, Localizations.creatingOn)
|
||||
XCTAssertNil(alert.message)
|
||||
XCTAssertEqual(alert.alertActions.count, 4)
|
||||
|
||||
XCTAssertEqual(alert.alertActions[2].title, Localizations.selfHosted)
|
||||
try await alert.tapAction(title: Localizations.selfHosted)
|
||||
XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .europe))
|
||||
}
|
||||
|
||||
/// `presentRegionSelectorAlert(title:currentRegion)` with current region as nil default to us
|
||||
func test_presentRegionSelectorAlert_nil() async throws {
|
||||
await subject.presentRegionSelectorAlert(title: Localizations.loggingInOn, currentRegion: nil)
|
||||
|
||||
let alert = try XCTUnwrap(coordinator.alertShown.last)
|
||||
XCTAssertEqual(alert.title, Localizations.loggingInOn)
|
||||
XCTAssertNil(alert.message)
|
||||
XCTAssertEqual(alert.alertActions.count, 4)
|
||||
|
||||
XCTAssertEqual(alert.alertActions[2].title, Localizations.selfHosted)
|
||||
try await alert.tapAction(title: Localizations.selfHosted)
|
||||
XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .unitedStates))
|
||||
}
|
||||
|
||||
/// `loadRegion()` with pre auth region as nil default to us
|
||||
func test_loadRegion_nil() async throws {
|
||||
stateService.preAuthEnvironmentUrls = nil
|
||||
await subject.loadRegion()
|
||||
XCTAssertTrue(regionDelegate.setRegionCalled)
|
||||
XCTAssertEqual(regionDelegate.setRegionType, .unitedStates)
|
||||
XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.unitedStates.defaultURLs)
|
||||
}
|
||||
|
||||
/// `loadRegion()` with pre auth region
|
||||
func test_loadRegion_us() async throws {
|
||||
stateService.preAuthEnvironmentUrls = .defaultUS
|
||||
await subject.loadRegion()
|
||||
XCTAssertTrue(regionDelegate.setRegionCalled)
|
||||
XCTAssertEqual(regionDelegate.setRegionType, .unitedStates)
|
||||
XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.unitedStates.defaultURLs)
|
||||
}
|
||||
|
||||
/// `loadRegion()` with pre auth region
|
||||
func test_loadRegion_eu() async throws {
|
||||
stateService.preAuthEnvironmentUrls = .defaultEU
|
||||
await subject.loadRegion()
|
||||
XCTAssertTrue(regionDelegate.setRegionCalled)
|
||||
XCTAssertEqual(regionDelegate.setRegionType, .europe)
|
||||
XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.europe.defaultURLs)
|
||||
}
|
||||
|
||||
/// `loadRegion()` with pre auth region
|
||||
func test_loadRegion_selfHosted() async throws {
|
||||
stateService.preAuthEnvironmentUrls = EnvironmentUrlData(base: URL(string: "https://selfhosted.com"))
|
||||
await subject.loadRegion()
|
||||
XCTAssertTrue(regionDelegate.setRegionCalled)
|
||||
XCTAssertEqual(regionDelegate.setRegionType, .selfHosted)
|
||||
XCTAssertEqual(regionDelegate.setRegionUrls, EnvironmentUrlData(base: URL(string: "https://selfhosted.com")))
|
||||
}
|
||||
}
|
||||
|
||||
class MockRegionDelegate: RegionDelegate {
|
||||
var setRegionCalled = false
|
||||
var setRegionType: RegionType?
|
||||
var setRegionUrls: EnvironmentUrlData?
|
||||
|
||||
func setRegion(_ region: BitwardenShared.RegionType, _ urls: BitwardenShared.EnvironmentUrlData) async {
|
||||
setRegionCalled = true
|
||||
setRegionType = region
|
||||
setRegionUrls = urls
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,24 @@ import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - AppLinksError
|
||||
|
||||
/// The errors thrown from a `AppProcessor`.
|
||||
///
|
||||
enum AppProcessorError: Error {
|
||||
/// The received URL from AppLinks is malformed.
|
||||
case appLinksInvalidURL
|
||||
|
||||
/// The received URL from AppLinks does not have the correct parameters.
|
||||
case appLinksInvalidParametersForPath
|
||||
|
||||
/// The received URL from AppLinks does not have a valid path.
|
||||
case appLinksInvalidPath
|
||||
|
||||
/// The operation to execute is invalid.
|
||||
case invalidOperation
|
||||
}
|
||||
|
||||
/// The `AppProcessor` processes actions received at the application level and contains the logic
|
||||
/// to control the top-level flow through the app.
|
||||
///
|
||||
@ -137,6 +155,44 @@ public class AppProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming URL from iOS AppLinks and redirect it to the correct navigation within the App
|
||||
///
|
||||
/// - Parameter incomingURL: The URL handled from AppLinks.
|
||||
///
|
||||
public func handleAppLinks(incomingURL: URL) {
|
||||
guard let sanatizedUrl = URL(string: incomingURL.absoluteString.replacingOccurrences(of: "/#/", with: "/")),
|
||||
let components = URLComponents(url: sanatizedUrl, resolvingAgainstBaseURL: true) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for specific URL components that you need.
|
||||
guard let params = components.queryItems,
|
||||
let host = components.host else {
|
||||
services.errorReporter.log(error: AppProcessorError.appLinksInvalidURL)
|
||||
return
|
||||
}
|
||||
|
||||
guard components.path == "/finish-signup" else {
|
||||
services.errorReporter.log(error: AppProcessorError.appLinksInvalidPath)
|
||||
return
|
||||
}
|
||||
guard let email = params.first(where: { $0.name == "email" })?.value,
|
||||
let verificationToken = params.first(where: { $0.name == "token" })?.value,
|
||||
let fromEmail = params.first(where: { $0.name == "fromEmail" })?.value
|
||||
else {
|
||||
services.errorReporter.log(error: AppProcessorError.appLinksInvalidParametersForPath)
|
||||
return
|
||||
}
|
||||
|
||||
coordinator?.navigate(to: AppRoute.auth(
|
||||
AuthRoute.completeRegistrationFromAppLink(
|
||||
emailVerificationToken: verificationToken,
|
||||
userEmail: email,
|
||||
fromEmail: Bool(fromEmail) ?? true,
|
||||
region: host.contains(RegionType.europe.baseUrlDescription) ? .europe : .unitedStates
|
||||
)))
|
||||
}
|
||||
|
||||
// MARK: Autofill Methods
|
||||
|
||||
/// Returns a `ASPasswordCredential` that matches the user-requested credential which can be
|
||||
@ -451,10 +507,4 @@ extension AppProcessor: Fido2UserInterfaceHelperDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AppProcessorError
|
||||
|
||||
/// Errors that can happen inside the `AppProcessor`.
|
||||
enum AppProcessorError: Error {
|
||||
/// The operation to execute is invalid.
|
||||
case invalidOperation
|
||||
} // swiftlint:disable:this file_length
|
||||
// swiftlint:disable:this file_length
|
||||
|
||||
@ -209,6 +209,107 @@ class AppProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_body
|
||||
XCTAssertIdentical(syncService.delegate, subject)
|
||||
}
|
||||
|
||||
/// `handleAppLinks(URL)` navigates the user based on the input URL.
|
||||
func test_init_handleAppLinks() {
|
||||
let url = URL(string:
|
||||
"https://bitwarden.com/#/finish-signup?email=example@email.com&token=verificationtoken&fromEmail=true"
|
||||
)
|
||||
subject.handleAppLinks(incomingURL: url!)
|
||||
|
||||
XCTAssertEqual(coordinator.routes.last, .auth(.completeRegistrationFromAppLink(
|
||||
emailVerificationToken: "verificationtoken",
|
||||
userEmail: "example@email.com",
|
||||
fromEmail: true,
|
||||
region: .unitedStates
|
||||
)))
|
||||
}
|
||||
|
||||
/// `handleAppLinks(URL)` navigates the user based on the input URL with EU region.
|
||||
func test_init_handleAppLinks_regionEU() {
|
||||
let url = URL(string:
|
||||
"https://bitwarden.eu/#/finish-signup?email=example@email.com&token=verificationtoken&fromEmail=true"
|
||||
)
|
||||
subject.handleAppLinks(incomingURL: url!)
|
||||
|
||||
XCTAssertEqual(coordinator.routes.last, .auth(.completeRegistrationFromAppLink(
|
||||
emailVerificationToken: "verificationtoken",
|
||||
userEmail: "example@email.com",
|
||||
fromEmail: true,
|
||||
region: .europe
|
||||
)))
|
||||
}
|
||||
|
||||
/// `handleAppLinks(URL)` navigates the user based on the input URL with wrong fromEmail value.
|
||||
func test_init_handleAppLinks_fromEmail_notBool() {
|
||||
let url = URL(string:
|
||||
"https://bitwarden.eu/#/finish-signup?email=example@email.com&token=verificationtoken&fromEmail=potato"
|
||||
)
|
||||
subject.handleAppLinks(incomingURL: url!)
|
||||
|
||||
XCTAssertEqual(coordinator.routes.last, .auth(.completeRegistrationFromAppLink(
|
||||
emailVerificationToken: "verificationtoken",
|
||||
userEmail: "example@email.com",
|
||||
fromEmail: true,
|
||||
region: .europe
|
||||
)))
|
||||
}
|
||||
|
||||
/// `handleAppLinks(URL)` checks error report for `.appLinksInvalidURL`.
|
||||
func test_init_handleAppLinks_invalidURL() {
|
||||
let noPathUrl = URL(string: "https://bitwarden.com/#/email=example@email.com&token=verificationtoken")
|
||||
subject.handleAppLinks(incomingURL: noPathUrl!)
|
||||
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidURL)
|
||||
XCTAssertEqual(errorReporter.errors.count, 1)
|
||||
errorReporter.errors.removeAll()
|
||||
|
||||
let noParamsUrl = URL(string: "https://bitwarden.com/#/finish-signup/")
|
||||
subject.handleAppLinks(incomingURL: noParamsUrl!)
|
||||
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidURL)
|
||||
XCTAssertEqual(errorReporter.errors.count, 1)
|
||||
errorReporter.errors.removeAll()
|
||||
|
||||
let invalidHostUrl = URL(string: "/finish-signup?email=example@email.com")
|
||||
subject.handleAppLinks(incomingURL: invalidHostUrl!)
|
||||
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidURL)
|
||||
XCTAssertEqual(errorReporter.errors.count, 1)
|
||||
}
|
||||
|
||||
/// `handleAppLinks(URL)` checks error report for `.appLinksInvalidPath`.
|
||||
func test_init_handleAppLinks_invalidPath() {
|
||||
let url = URL(
|
||||
string: "https://bitwarden.com/#/not-valid?email=example@email.com&token=verificationtoken&fromEmail=true"
|
||||
)
|
||||
subject.handleAppLinks(incomingURL: url!)
|
||||
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidPath)
|
||||
}
|
||||
|
||||
/// `handleAppLinks(URL)` checks error report for `.appLinksInvalidParametersForPath`.
|
||||
func test_init_handleAppLinks_invalidParametersForPath() {
|
||||
var url = URL(
|
||||
string: "https://bitwarden.com/#/finish-signup?token=verificationtoken&fromEmail=true"
|
||||
)
|
||||
subject.handleAppLinks(incomingURL: url!)
|
||||
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidParametersForPath)
|
||||
XCTAssertEqual(errorReporter.errors.count, 1)
|
||||
errorReporter.errors.removeAll()
|
||||
|
||||
url = URL(
|
||||
string: "https://bitwarden.com/#/finish-signup?email=example@email.com&fromEmail=true"
|
||||
)
|
||||
subject.handleAppLinks(incomingURL: url!)
|
||||
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidParametersForPath)
|
||||
XCTAssertEqual(errorReporter.errors.count, 1)
|
||||
errorReporter.errors.removeAll()
|
||||
|
||||
url = URL(
|
||||
string: "https://bitwarden.com/#/finish-signup?email=example@email.com&token=verificationtoken"
|
||||
)
|
||||
subject.handleAppLinks(incomingURL: url!)
|
||||
XCTAssertEqual(errorReporter.errors.last as? AppProcessorError, .appLinksInvalidParametersForPath)
|
||||
XCTAssertEqual(errorReporter.errors.count, 1)
|
||||
errorReporter.errors.removeAll()
|
||||
}
|
||||
|
||||
/// `init()` starts the upload-event timer and attempts to upload events.
|
||||
func test_init_uploadEvents() {
|
||||
XCTAssertNotNil(subject.sendEventTimer)
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "email_check.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
<svg width="413" height="114" viewBox="0 0 413 114" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2313_25967)">
|
||||
<path d="M260.144 44.6249V59.0358M192.864 14.7411L201.036 8.64331C204.02 6.41676 208.121 6.4503 211.068 8.72534L212.838 10.0913M164.614 35.8208L156.151 42.1362C154.052 43.7024 152.816 46.1675 152.816 48.7862V100.482C152.816 105.065 156.531 108.78 161.113 108.78H251.847C256.429 108.78 260.144 105.065 260.144 100.482V71.0536" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
|
||||
<path d="M165.375 54.5231V17.8322C165.375 16.3047 166.614 15.0664 168.141 15.0664H206.805" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
|
||||
<path d="M175.322 27.207H194.107" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
|
||||
<path d="M175.322 38.3867H193.56" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
|
||||
<path d="M175.322 49.5664H196.569" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
|
||||
<path d="M175.322 60.7461H203.134" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
|
||||
<path d="M257.859 106.495L223.49 74.7673C220.935 72.4091 217.586 71.0996 214.109 71.0996H197.24C193.649 71.0996 190.199 72.4967 187.619 74.9953L155.098 106.495" stroke="#175DDC" stroke-width="2.76577"/>
|
||||
<path d="M220.364 71.5786L231.035 65.459" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
|
||||
<line x1="1.38289" y1="-1.38289" x2="46.4939" y2="-1.38289" transform="matrix(0.858543 0.512741 0.512741 -0.858543 153.386 46.5527)" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round"/>
|
||||
<path d="M260.348 35.0897C260.348 51.7757 246.821 65.3024 230.135 65.3024C213.449 65.3024 199.922 51.7757 199.922 35.0897C199.922 18.4036 213.449 4.87695 230.135 4.87695C246.821 4.87695 260.348 18.4036 260.348 35.0897Z" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M256.301 35.0893C256.301 49.4393 244.533 61.0723 230.016 61.0723M230.016 9.10639C215.499 9.10639 203.731 20.7393 203.731 35.0893" stroke="#175DDC" stroke-width="1.38289" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M254.253 53.5511L258.87 58.1684L276.214 75.5124C277.564 76.8625 277.564 79.0515 276.214 80.4017L275.492 81.1232C274.142 82.4733 271.953 82.4733 270.603 81.1232L253.259 63.7792L248.642 59.1619" stroke="#175DDC" stroke-width="2.76577" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2313_25967">
|
||||
<rect width="143.82" height="112.705" fill="white" transform="translate(134.838 0.570312)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@ -914,6 +914,19 @@
|
||||
"FilePassword" = "File password";
|
||||
"ConfirmFilePassword" = "Confirm file password";
|
||||
"FilePasswordDescription" = "This password will be used to export and import this file";
|
||||
"CreatingOn" = "Creating on";
|
||||
"CheckYourEmail" = "Check your email";
|
||||
"FollowTheInstructionsInTheEmailSentToXToContinueCreatingYourAccount" = "Follow the instructions in the email sent to %1$@ to continue creating your account.";
|
||||
"NoEmailGoBackToEditYourEmailAddress" = "No email? **[Go back](https://)** to edit your email address.";
|
||||
"OrLogInYouMayAlreadyHaveAnAccount." = "Or **[log in](https://)**, you may already have an account.";
|
||||
"OpenEmailApp" = "Open email app";
|
||||
"SetPassword" = "Set password";
|
||||
"FinishCreatingYourAccountForXBySettingAPassword." = "Finish creating your account for %1$@ by setting a password.";
|
||||
"VerifyingEmail" = "Verifying email";
|
||||
"EmailVerified" = "Email verified";
|
||||
"AccountSuccessfullyCreated" = "Account successfully created!";
|
||||
"ByContinuingYouAgreeToTheTermsOfServiceAndPrivacyPolicy" = "By continuing, you agree to the **[Terms of Service](%1$@)** and **[Privacy Policy](%2$@)**";
|
||||
"GetAdviceAnnouncementsAndResearchOpportunitiesFromBitwardenInYourInboxUnsubscribeAtAnyTime." = "Get advice, announcements, and research opportunities from Bitwarden in your inbox. **[Unsubscribe](%1$@)** at any time.";
|
||||
"OrganizationUnassignedItemsMessageUSEUDescriptionLong" = "Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible.";
|
||||
"UnknownAccount" = "Unknown account";
|
||||
"UserVerificationForPasskey" = "User verification for passkey";
|
||||
|
||||
@ -14,4 +14,18 @@ enum ViewIdentifier {
|
||||
/// An identifier for the terms and privacy toggle.
|
||||
case termsAndPrivacy
|
||||
}
|
||||
|
||||
/// Identifiers used on the `StartRegistrationView`.
|
||||
///
|
||||
enum StartRegistration: String, Equatable, Hashable {
|
||||
/// An identifier for the terms and privacy toggle.
|
||||
case receiveMarketing
|
||||
}
|
||||
|
||||
/// Identifiers used on the `CompleteRegistrationView`.
|
||||
///
|
||||
enum CompleteRegistration: String, Equatable, Hashable {
|
||||
/// An identifier for the check for breaches toggle.
|
||||
case checkBreaches
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
// MARK: - AsyncButton
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A wrapper around SwiftUI's `Button` that used to trigger the region selector Alert
|
||||
///
|
||||
struct RegionSelector: View {
|
||||
// MARK: Properties
|
||||
|
||||
/// The async action to perform when the user interacts with the button.
|
||||
let action: () async -> Void
|
||||
|
||||
/// A binding to the toast to show.
|
||||
var regionName: String
|
||||
|
||||
/// The text that is shown before the action text
|
||||
var selectorLabel: String
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
Task {
|
||||
await action()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Group {
|
||||
Text("\(selectorLabel): ")
|
||||
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)
|
||||
+ Text(regionName).bold()
|
||||
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
}
|
||||
.styleGuide(.footnote)
|
||||
|
||||
Image(decorative: Asset.Images.downAngle)
|
||||
.scaledFrame(width: 12, height: 12)
|
||||
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("RegionSelectorDropdown")
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `RegionSelector`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - action: The async action to perform when the user interacts with this selector.
|
||||
///
|
||||
init(
|
||||
selectorLabel: String,
|
||||
regionName: String,
|
||||
action: @escaping () async -> Void
|
||||
) {
|
||||
self.action = action
|
||||
self.regionName = regionName
|
||||
self.selectorLabel = selectorLabel
|
||||
}
|
||||
}
|
||||