Skip to content

Commit

Permalink
BIT-100: Adds the known device API call (#18)
Browse files Browse the repository at this point in the history
Co-authored-by: Matt Czech <[email protected]>
  • Loading branch information
nathan-livefront and matt-livefront authored Sep 7, 2023
1 parent 3427490 commit 9426821
Show file tree
Hide file tree
Showing 12 changed files with 328 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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 email 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
}
}
Original file line number Diff line number Diff line change
@@ -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 protected]",
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 protected]",
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 protected]",
deviceIdentifier: "1234"
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

extension APITestData {
static let knownDeviceTrue = APITestData(data: Data("true".utf8))
static let knownDeviceFalse = APITestData(data: Data("false".utf8))
}
Original file line number Diff line number Diff line change
@@ -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,
]
}
}
Original file line number Diff line number Diff line change
@@ -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 protected]",
deviceIdentifier: "1234"
)

XCTAssertEqual(subject.headers.count, 2)
XCTAssertEqual(subject.headers["X-Request-Email"], "ZW1haWxAZXhhbXBsZS5jb20")
XCTAssertEqual(subject.headers["X-Device-Identifier"], "1234")
}
}
5 changes: 0 additions & 5 deletions BitwardenShared/Core/Auth/Services/API/DeviceAPIService.swift

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions BitwardenShared/UI/Platform/Application/Extensions/String.swift
Original file line number Diff line number Diff line change
@@ -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
))
}
}
Original file line number Diff line number Diff line change
@@ -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-")
}
}
2 changes: 2 additions & 0 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ targets:
excludes:
- "**/*Tests.*"
- "**/TestHelpers/*"
- "**/Fixtures/*"
dependencies:
- package: Networking
BitwardenSharedTests:
Expand All @@ -296,6 +297,7 @@ targets:
includes:
- "**/*Tests.*"
- "**/TestHelpers/*"
- "**/Fixtures/*"
- path: GlobalTestHelpers
dependencies:
- target: Bitwarden
Expand Down

0 comments on commit 9426821

Please sign in to comment.