Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BIT-100: Adds the known device API call #18

Merged
merged 4 commits into from
Sep 7, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,4 @@
extension APITestData {
static let knownDeviceTrue = loadFromBundle(resource: "KnownDeviceTrue", extension: "json")
static let knownDeviceFalse = loadFromBundle(resource: "KnownDeviceFalse", extension: "json")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
false
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
true
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")
}
}

This file was deleted.

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