From 8349f6e85fab854c3162135a49b49c417f01cb7b Mon Sep 17 00:00:00 2001 From: Nathan Ansel Date: Wed, 6 Sep 2023 09:46:31 -0500 Subject: [PATCH 1/4] BIT-100 Adds the known device API call --- .../KnownDeviceResponseModel.swift | 28 +++++++ .../KnownDeviceResponseModelTests.swift | 33 +++++++++ .../API/Device/DeviceAPIService.swift | 25 +++++++ .../API/Device/DeviceAPIServiceTests.swift | 73 +++++++++++++++++++ .../Fixtures/APITestData+KnownDevice.swift | 4 + .../Fixtures/KnownDeviceFalse.json | 1 + .../KnownDevice/Fixtures/KnownDeviceTrue.json | 1 + .../KnownDevice/KnownDeviceRequest.swift | 28 +++++++ .../KnownDevice/KnownDeviceRequestTests.swift | 47 ++++++++++++ .../Auth/Services/API/DeviceAPIService.swift | 5 -- .../Application/Extensions/String.swift | 51 +++++++++++++ .../Application/Extensions/StringTests.swift | 31 ++++++++ project.yml | 2 + 13 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 BitwardenShared/Core/Auth/Models/Response/KnownDevice/KnownDeviceResponseModel.swift create mode 100644 BitwardenShared/Core/Auth/Models/Response/KnownDevice/KnownDeviceResponseModelTests.swift create mode 100644 BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift create mode 100644 BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIServiceTests.swift create mode 100644 BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/APITestData+KnownDevice.swift create mode 100644 BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceFalse.json create mode 100644 BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceTrue.json create mode 100644 BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/KnownDeviceRequest.swift create mode 100644 BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/KnownDeviceRequestTests.swift delete mode 100644 BitwardenShared/Core/Auth/Services/API/DeviceAPIService.swift create mode 100644 BitwardenShared/UI/Platform/Application/Extensions/String.swift create mode 100644 BitwardenShared/UI/Platform/Application/Extensions/StringTests.swift diff --git a/BitwardenShared/Core/Auth/Models/Response/KnownDevice/KnownDeviceResponseModel.swift b/BitwardenShared/Core/Auth/Models/Response/KnownDevice/KnownDeviceResponseModel.swift new file mode 100644 index 0000000000..998ff12549 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/KnownDevice/KnownDeviceResponseModel.swift @@ -0,0 +1,28 @@ +import Foundation +import Networking + +// MARK: - KnownDeviceResponse + +/// An object containing a value defining if this device has previously logged into this account or not. +struct KnownDeviceResponseModel: JSONResponse { + static var decoder = JSONDecoder() + + // MARK: Properties + + /// A flag indicating if this device is known or not. + var isKnownDevice: Bool + + // MARK: Initialization + + /// Creates a new `KnownDeviceResponseModel` instance. + /// + /// - Parameter isKnownDevice: A flag indicating if this device is known or not. + init(isKnownDevice: Bool) { + self.isKnownDevice = isKnownDevice + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + isKnownDevice = try container.decode(Bool.self) + } +} diff --git a/BitwardenShared/Core/Auth/Models/Response/KnownDevice/KnownDeviceResponseModelTests.swift b/BitwardenShared/Core/Auth/Models/Response/KnownDevice/KnownDeviceResponseModelTests.swift new file mode 100644 index 0000000000..344b014139 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/KnownDevice/KnownDeviceResponseModelTests.swift @@ -0,0 +1,33 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - KnownDeviceResponseModelTests + +class KnownDeviceResponseModelTests: BitwardenTestCase { + // MARK: Init + + /// `init(isKnownDevice:)` sets the corresponding values. + func test_init() { + let subject = KnownDeviceResponseModel(isKnownDevice: true) + XCTAssertTrue(subject.isKnownDevice) + } + + // MARK: Decoding + + /// Validates decoding the `KnownDeviceFalse.json` fixture. + func test_decode_False() throws { + let json = APITestData.knownDeviceFalse.data + let decoder = JSONDecoder() + let subject = try decoder.decode(KnownDeviceResponseModel.self, from: json) + XCTAssertFalse(subject.isKnownDevice) + } + + /// Validates decoding the `KnownDeviceTrue.json` fixture. + func test_decode_True() throws { + let json = APITestData.knownDeviceTrue.data + let decoder = JSONDecoder() + let subject = try decoder.decode(KnownDeviceResponseModel.self, from: json) + XCTAssertTrue(subject.isKnownDevice) + } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift new file mode 100644 index 0000000000..49ada0cf1d --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift @@ -0,0 +1,25 @@ +// MARK: - DeviceAPIService + +/// A protocol for an API service used to make device requests. +/// +protocol DeviceAPIService { + /// Queries the API to determine if this device was previously associated with the email address. + /// + /// - Parameters: + /// - email: The email being used to log into the app. + /// - deviceIdentifier: The unique identifier for this device. + /// + /// - Returns: `true` if this device has been associated with this device, `false` otherwise. + /// + func knownDevice(email: String, deviceIdentifier: String) async throws -> Bool +} + +// MARK: - APIService + +extension APIService: DeviceAPIService { + func knownDevice(email: String, deviceIdentifier: String) async throws -> Bool { + let request = KnownDeviceRequest(email: email, deviceIdentifier: deviceIdentifier) + let response = try await apiService.send(request) + return response.isKnownDevice + } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIServiceTests.swift b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIServiceTests.swift new file mode 100644 index 0000000000..be5d53806d --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIServiceTests.swift @@ -0,0 +1,73 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - DeviceAPIServiceTests + +class DeviceAPIServiceTests: BitwardenTestCase { + // MARK: Properties + + var client: MockHTTPClient! + var subject: APIService! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + client = MockHTTPClient() + subject = APIService(client: client) + } + + override func tearDown() { + super.tearDown() + client = nil + subject = nil + } + + // MARK: Tests + + /// `knownDevice(email:deviceIdentifier:)` returns the correct value from the API with a successful request. + func test_knownDevice_success() async throws { + let resultData = APITestData.knownDeviceTrue + client.result = .httpSuccess(testData: resultData) + + let isKnownDevice = try await subject.knownDevice( + email: "email@example.com", + deviceIdentifier: "1234" + ) + + let request = try XCTUnwrap(client.requests.first) + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.url.relativePath, "/api/devices/knowndevice") + XCTAssertNil(request.body) + XCTAssertEqual(request.headers["X-Request-Email"], "ZW1haWxAZXhhbXBsZS5jb20") + XCTAssertEqual(request.headers["X-Device-Identifier"], "1234") + + XCTAssertTrue(isKnownDevice) + } + + /// `knownDevice(email:deviceIdentifier:)` throws a decoding error if the response is not the expected type. + func test_knownDevice_decodingFailure() async throws { + let resultData = APITestData(data: Data("this should fail".utf8)) + client.result = .httpSuccess(testData: resultData) + + await assertAsyncThrows { + _ = try await subject.knownDevice( + email: "email@example.com", + deviceIdentifier: "1234" + ) + } + } + + /// `knownDevice(email:deviceIdentifier:)` throws an error if the request fails. + func test_knownDevice_httpFailure() async { + client.result = .httpFailure() + + await assertAsyncThrows { + _ = try await subject.knownDevice( + email: "email@example.com", + deviceIdentifier: "1234" + ) + } + } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/APITestData+KnownDevice.swift b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/APITestData+KnownDevice.swift new file mode 100644 index 0000000000..6cf473f6dd --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/APITestData+KnownDevice.swift @@ -0,0 +1,4 @@ +extension APITestData { + static let knownDeviceTrue = loadFromBundle(resource: "KnownDeviceTrue", extension: "json") + static let knownDeviceFalse = loadFromBundle(resource: "KnownDeviceFalse", extension: "json") +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceFalse.json b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceFalse.json new file mode 100644 index 0000000000..c508d5366f --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceFalse.json @@ -0,0 +1 @@ +false diff --git a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceTrue.json b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceTrue.json new file mode 100644 index 0000000000..27ba77ddaf --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceTrue.json @@ -0,0 +1 @@ +true diff --git a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/KnownDeviceRequest.swift b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/KnownDeviceRequest.swift new file mode 100644 index 0000000000..d8e7ddd3db --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/KnownDeviceRequest.swift @@ -0,0 +1,28 @@ +import Foundation +import Networking + +// MARK: - KnownDeviceRequest + +/// A request for determining if this is a known device. +struct KnownDeviceRequest: Request { + typealias Response = KnownDeviceResponseModel + + let path = "/devices/knowndevice" + + let headers: [String: String] + + /// Creates a new `KnownDeviceRequest` instance. + /// + /// - Parameters: + /// - email: The email address for the user. + /// - deviceIdentifier: The unique identifier for this device. + /// + init(email: String, deviceIdentifier: String) { + let emailData = Data(email.utf8) + let emailEncoded = emailData.base64EncodedString().urlEncoded() + headers = [ + "X-Request-Email": emailEncoded, + "X-Device-Identifier": deviceIdentifier, + ] + } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/KnownDeviceRequestTests.swift b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/KnownDeviceRequestTests.swift new file mode 100644 index 0000000000..7c81db6855 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/KnownDeviceRequestTests.swift @@ -0,0 +1,47 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - KnownDeviceRequestTests + +class KnownDeviceRequestTests: BitwardenTestCase { + // MARK: Static Values + + /// `body` is `nil`. + func test_body() { + let subject = KnownDeviceRequest(email: "", deviceIdentifier: "") + XCTAssertNil(subject.body) + } + + /// `method` is `.get`. + func test_method() { + let subject = KnownDeviceRequest(email: "", deviceIdentifier: "") + XCTAssertEqual(subject.method, .get) + } + + /// `path` is the correct value. + func test_path() { + let subject = KnownDeviceRequest(email: "", deviceIdentifier: "") + XCTAssertEqual(subject.path, "/devices/knowndevice") + } + + /// `query` is empty. + func test_query() { + let subject = KnownDeviceRequest(email: "", deviceIdentifier: "") + XCTAssertTrue(subject.query.isEmpty) + } + + // MARK: Init + + /// `init()` encodes the provided values in to the request headers correctly. + func test_init() { + let subject = KnownDeviceRequest( + email: "email@example.com", + deviceIdentifier: "1234" + ) + + XCTAssertEqual(subject.headers.count, 2) + XCTAssertEqual(subject.headers["X-Request-Email"], "ZW1haWxAZXhhbXBsZS5jb20") + XCTAssertEqual(subject.headers["X-Device-Identifier"], "1234") + } +} diff --git a/BitwardenShared/Core/Auth/Services/API/DeviceAPIService.swift b/BitwardenShared/Core/Auth/Services/API/DeviceAPIService.swift deleted file mode 100644 index 2fbc65e9f6..0000000000 --- a/BitwardenShared/Core/Auth/Services/API/DeviceAPIService.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// A protocol for an API service used to make device requests. -/// -protocol DeviceAPIService {} - -extension APIService: DeviceAPIService {} diff --git a/BitwardenShared/UI/Platform/Application/Extensions/String.swift b/BitwardenShared/UI/Platform/Application/Extensions/String.swift new file mode 100644 index 0000000000..32dbce7d4c --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Extensions/String.swift @@ -0,0 +1,51 @@ +import Foundation + +// MARK: - URLDecodingError + +/// Errors that can be encountered when attempting to decode a string from it's url encoded format. +enum URLDecodingError: Error, Equatable { + /// The provided string is an invalid length. + /// + /// Base64 encoded strings are padded at the end with `=` characters to ensure that the length of the resulting + /// value is divisible by `4`. However, Base64 encoded strings _cannot_ have a remainder of `1` when divided by + /// `4`. + /// + /// Example: `YMFhY` is considered invalid, and attempting to decode this value from a url or header value will + /// throw this error. + /// + case invalidLength +} + +// MARK: - String + +extension String { + // MARK: Methods + + /// Creates a new string that has been encoded for use in a url or request header. + /// + /// - Returns: A `String` encoded for use in a url or request header. + /// + func urlEncoded() -> String { + replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + /// Creates a new string that has been decoded from a url or request header. + /// + /// - Throws: `URLDecodingError.invalidLength` if the length of this string is invalid. + /// + /// - Returns: A `String` decoded from use in a url or request header. + /// + func urlDecoded() throws -> String { + let remainder = count % 4 + guard remainder != 1 else { throw URLDecodingError.invalidLength } + + return replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + .appending(String( + repeating: "=", + count: remainder == 0 ? 0 : 4 - remainder + )) + } +} diff --git a/BitwardenShared/UI/Platform/Application/Extensions/StringTests.swift b/BitwardenShared/UI/Platform/Application/Extensions/StringTests.swift new file mode 100644 index 0000000000..db827e5bc4 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Extensions/StringTests.swift @@ -0,0 +1,31 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - StringTests + +class StringTests: BitwardenTestCase { + // MARK: Tests + + func test_urlDecoded_withInvalidString() { + let subject = "a_bc-" + + XCTAssertThrowsError(try subject.urlDecoded()) { error in + XCTAssertEqual(error as? URLDecodingError, .invalidLength) + } + } + + func test_urlDecoded_withValidString() throws { + let subject = "a_bcd-" + let decoded = try subject.urlDecoded() + + XCTAssertEqual(decoded, "a/bcd+==") + } + + func test_urlEncoded() { + let subject = "a/bcd+==" + let encoded = subject.urlEncoded() + + XCTAssertEqual(encoded, "a_bcd-") + } +} diff --git a/project.yml b/project.yml index f411d389a7..3fe5c3d005 100644 --- a/project.yml +++ b/project.yml @@ -281,6 +281,7 @@ targets: excludes: - "**/*Tests.*" - "**/TestHelpers/*" + - "**/Fixtures/*" dependencies: - package: Networking BitwardenSharedTests: @@ -296,6 +297,7 @@ targets: includes: - "**/*Tests.*" - "**/TestHelpers/*" + - "**/Fixtures/*" - path: GlobalTestHelpers dependencies: - target: Bitwarden From 864d13d88683848981a48214992c0f18b6223cd4 Mon Sep 17 00:00:00 2001 From: Nathan Ansel Date: Wed, 6 Sep 2023 15:00:37 -0500 Subject: [PATCH 2/4] BIT-100 Fixes a typo in documentation Co-authored-by: Matt Czech --- .../Core/Auth/Services/API/Device/DeviceAPIService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift index 49ada0cf1d..65b5a3cd2c 100644 --- a/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift +++ b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift @@ -9,7 +9,7 @@ protocol DeviceAPIService { /// - email: The email being used to log into the app. /// - deviceIdentifier: The unique identifier for this device. /// - /// - Returns: `true` if this device has been associated with this device, `false` otherwise. + /// - Returns: `true` if this email has been associated with this device, `false` otherwise. /// func knownDevice(email: String, deviceIdentifier: String) async throws -> Bool } From df6c2de47ec21a17a1709e7c694d8a0b1127f74c Mon Sep 17 00:00:00 2001 From: Nathan Ansel Date: Thu, 7 Sep 2023 11:15:11 -0500 Subject: [PATCH 3/4] BIT-100 Hardcodes KnownDevice fixtures --- .../KnownDevice/Fixtures/APITestData+KnownDevice.swift | 6 ++++-- .../API/Device/KnownDevice/Fixtures/KnownDeviceFalse.json | 1 - .../API/Device/KnownDevice/Fixtures/KnownDeviceTrue.json | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceFalse.json delete mode 100644 BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceTrue.json diff --git a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/APITestData+KnownDevice.swift b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/APITestData+KnownDevice.swift index 6cf473f6dd..8da118d521 100644 --- a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/APITestData+KnownDevice.swift +++ b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/APITestData+KnownDevice.swift @@ -1,4 +1,6 @@ +import Foundation + extension APITestData { - static let knownDeviceTrue = loadFromBundle(resource: "KnownDeviceTrue", extension: "json") - static let knownDeviceFalse = loadFromBundle(resource: "KnownDeviceFalse", extension: "json") + static let knownDeviceTrue = APITestData(data: Data("true".utf8)) + static let knownDeviceFalse = APITestData(data: Data("false".utf8)) } diff --git a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceFalse.json b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceFalse.json deleted file mode 100644 index c508d5366f..0000000000 --- a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceFalse.json +++ /dev/null @@ -1 +0,0 @@ -false diff --git a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceTrue.json b/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceTrue.json deleted file mode 100644 index 27ba77ddaf..0000000000 --- a/BitwardenShared/Core/Auth/Services/API/Device/KnownDevice/Fixtures/KnownDeviceTrue.json +++ /dev/null @@ -1 +0,0 @@ -true From d4e7f6d0d2f7dac8dbb44e5aca405d16e10b7489 Mon Sep 17 00:00:00 2001 From: Nathan Ansel Date: Thu, 7 Sep 2023 11:15:50 -0500 Subject: [PATCH 4/4] BIT-100 Adds method for loading json fixtures --- .../Core/Platform/Services/API/TestHelpers/APITestData.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/BitwardenShared/Core/Platform/Services/API/TestHelpers/APITestData.swift b/BitwardenShared/Core/Platform/Services/API/TestHelpers/APITestData.swift index 2a28bf030f..cd4f8fdb55 100644 --- a/BitwardenShared/Core/Platform/Services/API/TestHelpers/APITestData.swift +++ b/BitwardenShared/Core/Platform/Services/API/TestHelpers/APITestData.swift @@ -16,6 +16,10 @@ struct APITestData { fatalError("Unable to load data from \(resource).\(`extension`) in the bundle. Error: \(error)") } } + + static func loadFromJsonBundle(resource: String) -> APITestData { + loadFromBundle(resource: resource, extension: "json") + } } extension APITestData {