diff --git a/BitwardenShared/Core/Auth/Models/Request/VerifyEmailTokenRequestModel.swift b/BitwardenShared/Core/Auth/Models/Request/VerifyEmailTokenRequestModel.swift new file mode 100644 index 000000000..1912a0c35 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Request/VerifyEmailTokenRequestModel.swift @@ -0,0 +1,22 @@ +import Foundation +import Networking + +// MARK: - VerifyEmailTokenRequestRequestModel + +/// The data to include in the body of a `VerifyEmailTokenRequestRequest`. +/// +struct VerifyEmailTokenRequestModel: Equatable { + // MARK: Properties + + /// The email being used to create the account. + let email: String + + /// The token used to verify the email. + let emailVerificationToken: String +} + +// MARK: JSONRequestBody + +extension VerifyEmailTokenRequestModel: JSONRequestBody { + static let encoder = JSONEncoder() +} diff --git a/BitwardenShared/Core/Auth/Services/API/Account/AccountAPIService.swift b/BitwardenShared/Core/Auth/Services/API/Account/AccountAPIService.swift index e515079f3..e508bee06 100644 --- a/BitwardenShared/Core/Auth/Services/API/Account/AccountAPIService.swift +++ b/BitwardenShared/Core/Auth/Services/API/Account/AccountAPIService.swift @@ -102,6 +102,13 @@ protocol AccountAPIService { /// func updateTempPassword(_ requestModel: UpdateTempPasswordRequestModel) async throws + /// Verify if the verification token received by email is still valid. + /// + /// - Parameter email: The email being used to create the account. + /// - Parameter emailVerificationToken: The token used to verify the email. + /// + func verifyEmailToken(email: String, emailVerificationToken: String) async throws + /// Verifies that the entered one-time password matches the one sent to the user. /// /// - Parameter otp: The user's one-time password to verify. @@ -192,6 +199,16 @@ extension APIService: AccountAPIService { _ = try await apiService.send(UpdateTempPasswordRequest(requestModel: requestModel)) } + func verifyEmailToken(email: String, emailVerificationToken: String) async throws { + let request = VerifyEmailTokenRequest( + requestModel: VerifyEmailTokenRequestModel( + email: email, + emailVerificationToken: emailVerificationToken + ) + ) + _ = try await identityService.send(request) + } + func verifyOtp(_ otp: String) async throws { _ = try await apiService.send(VerifyOtpRequest(requestModel: VerifyOtpRequestModel(otp: otp))) } diff --git a/BitwardenShared/Core/Auth/Services/API/Account/AccountAPIServiceTests.swift b/BitwardenShared/Core/Auth/Services/API/Account/AccountAPIServiceTests.swift index 5226644fa..437c95e4f 100644 --- a/BitwardenShared/Core/Auth/Services/API/Account/AccountAPIServiceTests.swift +++ b/BitwardenShared/Core/Auth/Services/API/Account/AccountAPIServiceTests.swift @@ -4,6 +4,7 @@ import XCTest // MARK: - AccountAPIServiceTests +// swiftlint:disable file_length class AccountAPIServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_length // MARK: Properties @@ -386,6 +387,36 @@ class AccountAPIServiceTests: BitwardenTestCase { // swiftlint:disable:this type XCTAssertEqual(client.requests[0].url.absoluteString, "https://example.com/api/accounts/update-temp-password") } + /// `verifyEmailToken()` performs a request to verify if the verification token received by email is still valid. + func test_verifyEmailToken() async throws { + client.result = .httpSuccess(testData: .emptyResponse) + + try await subject.verifyEmailToken( + email: "example@email.com", + emailVerificationToken: "verification-token" + ) + + 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/verification-email-clicked" + ) + } + + /// `verifyEmailToken()` throws error when call fails. + func test_verifyEmailToken_httpFailure() async throws { + client.result = .httpFailure() + + await assertAsyncThrows { + try await subject.verifyEmailToken( + email: "example@email.com", + emailVerificationToken: "verification-token" + ) + } + } + /// `verifyOtp()` performs a request to verify a one-time password for the user. func test_verifyOtp() async throws { client.result = .httpSuccess(testData: .emptyResponse) diff --git a/BitwardenShared/Core/Auth/Services/API/Account/Fixtures/APITestData+Account.swift b/BitwardenShared/Core/Auth/Services/API/Account/Fixtures/APITestData+Account.swift index c7e1830ed..d5ff4d53c 100644 --- a/BitwardenShared/Core/Auth/Services/API/Account/Fixtures/APITestData+Account.swift +++ b/BitwardenShared/Core/Auth/Services/API/Account/Fixtures/APITestData+Account.swift @@ -40,4 +40,8 @@ extension APITestData { static let startRegistrationInvalidEmailFormat = loadFromJsonBundle(resource: "StartRegistrationInvalidEmailFormat") static let startRegistrationCaptchaFailure = loadFromJsonBundle(resource: "StartRegistrationCaptchaFailure") static let startRegistrationSuccess = loadFromBundle(resource: "StartRegistrationSuccess", extension: "txt") + + // MARK: Verify Email Token + + static let verifyEmailTokenExpiredLink = loadFromJsonBundle(resource: "VerifyEmailTokenExpiredLink") } diff --git a/BitwardenShared/Core/Auth/Services/API/Account/Fixtures/VerifyEmailTokenExpiredLink.json b/BitwardenShared/Core/Auth/Services/API/Account/Fixtures/VerifyEmailTokenExpiredLink.json new file mode 100644 index 000000000..317077af5 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Account/Fixtures/VerifyEmailTokenExpiredLink.json @@ -0,0 +1,8 @@ +{ + "message":"Expired link. Please restart registration or try logging in. You may already have an account", + "validationErrors": null, + "exceptionMessage":null, + "exceptionStackTrace":null, + "innerExceptionMessage":null, + "object":"error" +} diff --git a/BitwardenShared/Core/Auth/Services/API/Account/Requests/VerifyEmailTokenRequest.swift b/BitwardenShared/Core/Auth/Services/API/Account/Requests/VerifyEmailTokenRequest.swift new file mode 100644 index 000000000..ce8dc1c2e --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Account/Requests/VerifyEmailTokenRequest.swift @@ -0,0 +1,52 @@ +import Foundation +import Networking + +// MARK: - VerifyEmailTokenRequestError + +/// Errors that can occur when sending a `VerifyEmailTokenRequest`. +enum VerifyEmailTokenRequestError: Error, Equatable { + /// The token provided by email is expired or user is already used. + /// + case tokenExpired +} + +// MARK: - VerificationEmailClickedRequest + +/// The API request sent when verifying the token received by email. +/// +struct VerifyEmailTokenRequest: Request { + typealias Response = EmptyResponse + + typealias Body = VerifyEmailTokenRequestModel + + /// The body of this request. + var body: VerifyEmailTokenRequestModel? { + requestModel + } + + /// The HTTP method for this request. + let method: HTTPMethod = .post + + /// The URL path for this request. + var path: String = "/accounts/register/verification-email-clicked" + + /// The request details to include in the body of the request. + let requestModel: VerifyEmailTokenRequestModel + + // MARK: Methods + + func validate(_ response: HTTPResponse) throws { + switch response.statusCode { + case 400 ..< 500: + guard let errorResponse = try? ErrorResponseModel(response: response) else { return } + + if errorResponse.message.contains("Expired link") { + throw VerifyEmailTokenRequestError.tokenExpired + } + + throw ServerError.error(errorResponse: errorResponse) + default: + return + } + } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Account/Requests/VerifyEmailTokenRequestTests.swift b/BitwardenShared/Core/Auth/Services/API/Account/Requests/VerifyEmailTokenRequestTests.swift new file mode 100644 index 000000000..0d6d39b43 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Account/Requests/VerifyEmailTokenRequestTests.swift @@ -0,0 +1,89 @@ +import Networking +import XCTest + +@testable import BitwardenShared + +// MARK: - VerifyEmailTokenRequestTests + +class VerifyEmailTokenRequestTests: BitwardenTestCase { + // MARK: Properties + + var subject: VerifyEmailTokenRequest! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + subject = VerifyEmailTokenRequest( + requestModel: VerifyEmailTokenRequestModel( + email: "example@email.com", + emailVerificationToken: "email-verification-token" + ) + ) + } + + 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/verification-email-clicked") + } + + /// Validate that the body is not nil. + func test_body() { + XCTAssertNotNil(subject.body) + } + + /// `validate(_:)` with a `400` status code and an expired link error in the response body + /// throws an `.tokenExpired` error. + func test_validate_with400ExpiredLink() throws { + let response = HTTPResponse.failure( + statusCode: 400, + body: APITestData.verifyEmailTokenExpiredLink.data + ) + + XCTAssertThrowsError(try subject.validate(response)) { error in + XCTAssertEqual( + error as? VerifyEmailTokenRequestError, + .tokenExpired + ) + } + } + + /// `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() + + 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") + XCTAssertEqual(subject.body?.emailVerificationToken, "email-verification-token") + } +}