[BITAU-134] [BITAU-121] Create Shared CoreData Store (#937)

This commit is contained in:
Brant DeBow 2024-09-19 10:20:57 -04:00 committed by GitHub
parent 235f340f67
commit fe37cb6297
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1003 additions and 0 deletions

View File

@ -0,0 +1,135 @@
import CoreData
// MARK: - AuthenticatorStoreType
/// A type of data store.
///
public enum AuthenticatorBridgeStoreType {
/// The data store is stored only in memory and isn't persisted to the device. This is used for
/// unit testing.
case memory
/// The data store is persisted to the device.
case persisted
}
// MARK: - AuthenticatorDataStore
/// A data store that manages persisting data across app launches in Core Data.
///
public class AuthenticatorBridgeDataStore {
// MARK: Properties
/// A managed object context which executes on a background queue.
private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = persistentContainer.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return context
}()
/// The service used by the application to report non-fatal errors.
let errorReporter: ErrorReporter
/// The CoreData model name.
private let modelName = "Bitwarden-Authenticator"
/// The Core Data persistent container.
public let persistentContainer: NSPersistentContainer
// MARK: Initialization
/// Initialize a `AuthenticatorBridgeDataStore`.
///
/// - Parameters:
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - groupIdentifier: The app group identifier for the shared resource.
/// - storeType: The type of store to create.
///
public init(
errorReporter: ErrorReporter,
groupIdentifier: String,
storeType: AuthenticatorBridgeStoreType = .persisted
) {
self.errorReporter = errorReporter
#if SWIFT_PACKAGE
let bundle = Bundle.module
#else
let bundle = Bundle(for: type(of: self))
#endif
let modelURL = bundle.url(forResource: modelName, withExtension: "momd")!
let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL)!
persistentContainer = NSPersistentContainer(
name: modelName,
managedObjectModel: managedObjectModel
)
let storeDescription: NSPersistentStoreDescription
switch storeType {
case .memory:
storeDescription = NSPersistentStoreDescription(url: URL(fileURLWithPath: "/dev/null"))
case .persisted:
let storeURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier)!
.appendingPathComponent("\(modelName).sqlite")
storeDescription = NSPersistentStoreDescription(url: storeURL)
}
persistentContainer.persistentStoreDescriptions = [storeDescription]
persistentContainer.loadPersistentStores { _, error in
if let error {
errorReporter.log(error: error)
}
}
persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
}
// MARK: Methods
/// Executes a batch delete request and merges the changes into the background and view contexts.
///
/// - Parameter request: The batch delete request to perform.
///
public func executeBatchDelete(_ request: NSBatchDeleteRequest) async throws {
try await backgroundContext.perform {
try self.backgroundContext.executeAndMergeChanges(
batchDeleteRequest: request,
additionalContexts: [self.persistentContainer.viewContext]
)
}
}
/// Executes a batch insert request and merges the changes into the background and view contexts.
///
/// - Parameter request: The batch insert request to perform.
///
public func executeBatchInsert(_ request: NSBatchInsertRequest) async throws {
try await backgroundContext.perform {
try self.backgroundContext.executeAndMergeChanges(
batchInsertRequest: request,
additionalContexts: [self.persistentContainer.viewContext]
)
}
}
/// Executes a batch delete and batch insert request and merges the changes into the background
/// and view contexts.
///
/// - Parameters:
/// - deleteRequest: The batch delete request to perform.
/// - insertRequest: The batch insert request to perform.
///
public func executeBatchReplace(
deleteRequest: NSBatchDeleteRequest,
insertRequest: NSBatchInsertRequest
) async throws {
try await backgroundContext.perform {
try self.backgroundContext.executeAndMergeChanges(
batchDeleteRequest: deleteRequest,
batchInsertRequest: insertRequest,
additionalContexts: [self.persistentContainer.viewContext]
)
}
}
}

View File

@ -0,0 +1,80 @@
import CoreData
import Foundation
/// A data model for persisting authenticator items into the shared CoreData store.
///
public class AuthenticatorBridgeItemData: NSManagedObject, CodableModelData {
public typealias Model = AuthenticatorBridgeItemDataModel
// MARK: Properties
/// The item's ID
@NSManaged public var id: String
/// The data model encoded as encrypted JSON data
@NSManaged public var modelData: Data?
/// The ID of the user who owns the item
@NSManaged public var userId: String
// MARK: Initialization
/// Initialize an `AuthenticatorBridgeItemData` object for insertion into the managed object context
///
/// - Parameters:
/// - context: The managed object context to insert the initialized item
/// - userId: The ID of the user who owns the item
/// - authenticatorItem: the `AuthenticatorBridgeItemDataModel` used to create the item
convenience init(
context: NSManagedObjectContext,
userId: String,
authenticatorItem: AuthenticatorBridgeItemDataModel
) throws {
self.init(context: context)
id = authenticatorItem.id
model = authenticatorItem
self.userId = userId
}
}
// MARK: - ManagedUserObject
extension AuthenticatorBridgeItemData: ManagedUserObject {
/// Create an NSPredicate based on both the userId and id properties.
///
/// - Parameters:
/// - userId: The userId to match in the predicate
/// - id: The id to match in the predicate
/// - Returns: The NSPredicate for searching/filtering by userId and id
///
static func userIdAndIdPredicate(userId: String, id: String) -> NSPredicate {
NSPredicate(
format: "%K == %@ AND %K == %@",
#keyPath(AuthenticatorBridgeItemData.userId),
userId,
#keyPath(AuthenticatorBridgeItemData.id),
id
)
}
/// Create an NSPredicate based on the userId property.
///
/// - Parameter userId: The userId to match in the predicate
/// - Returns: The NSPredicate for searching/filtering by userId
///
static func userIdPredicate(userId: String) -> NSPredicate {
NSPredicate(format: "%K == %@", #keyPath(AuthenticatorBridgeItemData.userId), userId)
}
/// Updates the object with the properties from the `value` struct and the given `userId`
///
/// - Parameters:
/// - value: the `AuthenticatorBridgeItemDataModel` to use in updating the object
/// - userId: userId to update this object with.
///
func update(with value: AuthenticatorBridgeItemDataModel, userId: String) throws {
id = value.id
model = value
self.userId = userId
}
}

View File

@ -0,0 +1,39 @@
import Foundation
/// A struct for storing information about items that are shared between the Bitwarden and Authenticator apps.
///
public struct AuthenticatorBridgeItemDataModel: Codable, Equatable {
// MARK: Properties
/// Bool indicating if this item is a favorite.
public let favorite: Bool
/// The unique id of the item.
public let id: String
/// The name of the item.
public let name: String
/// The TOTP key used to generate codes.
public let totpKey: String?
/// The username of the Bitwarden account that owns this iteam.
public let username: String?
/// Initialize an `AuthenticatorBridgeItemDataModel` with the values provided.
///
/// - Parameters:
/// - favorite: Bool indicating if this item is a favorite.
/// - id: The unique id of the item.
/// - name: The name of the item.
/// - totpKey: The TOTP key used to generate codes.
/// - username: The username of the Bitwarden account that owns this iteam.
///
public init(favorite: Bool, id: String, name: String, totpKey: String?, username: String?) {
self.favorite = favorite
self.id = id
self.name = name
self.totpKey = totpKey
self.username = username
}
}

View File

@ -0,0 +1,115 @@
import Foundation
// MARK: - AuthenticatorBridgeItemService
/// A service that provides a number of convenience methods for working with the shared
/// `AuthenticatorBridgeItemData` objects.
///
public protocol AuthenticatorBridgeItemService {
/// Removes all items that are owned by the specific userId
///
/// - Parameter userId: the id of the user for which to delete all items.
///
func deleteAllForUserId(_ userId: String) async throws
/// Fetches all items that are owned by the specific userId
///
/// - Parameter userId: the id of the user for which to fetch items.
///
func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel]
/// Inserts the list of items into the store for the given userId.
///
/// - Parameters:
/// - items: The list of `AuthenticatorBridgeItemDataModel` to be inserted into the store.
/// - userId: the id of the user for which to insert the items.
///
func insertItems(_ items: [AuthenticatorBridgeItemDataModel],
forUserId userId: String) async throws
/// Deletes all existing items for a given user and inserts new items for the list of items provided.
///
/// - Parameters:
/// - items: The new items to be inserted into the store
/// - userId: The userId of the items to be removed and then replaces with items.
///
func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel],
forUserId userId: String) async throws
}
/// A concrete implementation of the `AuthenticatorBridgeItemService` protocol.
///
public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemService {
// MARK: Properties
/// The CoreData store for working with shared data.
let dataStore: AuthenticatorBridgeDataStore
/// The keychain repository for working with the shared key.
let sharedKeychainRepository: SharedKeychainRepository
// MARK: Initialization
/// Initialize a `DefaultAuthenticatorBridgeItemService`
///
/// - Parameters:
/// - dataStore: The CoreData store for working with shared data
/// - sharedKeychainRepository: The keychain repository for working with the shared key.
///
init(dataStore: AuthenticatorBridgeDataStore, sharedKeychainRepository: SharedKeychainRepository) {
self.dataStore = dataStore
self.sharedKeychainRepository = sharedKeychainRepository
}
// MARK: Methods
/// Removes all items that are owned by the specific userId
///
/// - Parameter userId: the id of the user for which to delete all items.
///
public func deleteAllForUserId(_ userId: String) async throws {
try await dataStore.executeBatchDelete(AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId))
}
/// Fetches all items that are owned by the specific userId
///
/// - Parameter userId: the id of the user for which to fetch items.
///
public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] {
let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId)
let result = try dataStore.backgroundContext.fetch(fetchRequest)
return result.compactMap { data in
data.model
}
}
/// Inserts the list of items into the store for the given userId.
///
/// - Parameters:
/// - items: The list of `AuthenticatorBridgeItemDataModel` to be inserted into the store.
/// - userId: the id of the user for which to insert the items.
///
public func insertItems(_ items: [AuthenticatorBridgeItemDataModel],
forUserId userId: String) async throws {
try await dataStore.executeBatchInsert(
AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId)
)
}
/// Deletes all existing items for a given user and inserts new items for the list of items provided.
///
/// - Parameters:
/// - items: The new items to be inserted into the store
/// - userId: The userId of the items to be removed and then replaces with items.
///
public func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel],
forUserId userId: String) async throws {
let deleteRequest = AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId)
let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId)
try await dataStore.executeBatchReplace(
deleteRequest: deleteRequest,
insertRequest: insertRequest
)
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23G93" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AuthenticatorBridgeItemData" representedClassName="AuthenticatorBridgeKit.AuthenticatorBridgeItemData" syncable="YES">
<attribute name="id" attributeType="String"/>
<attribute name="modelData" attributeType="Binary"/>
<attribute name="userId" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="userId"/>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>

View File

@ -0,0 +1,40 @@
import CoreData
import OSLog
/// A protocol for a `NSManagedObject` which persists a data model as JSON encoded data. The model
/// can be set via the `model` property which encodes the model to the data property, which should
/// be a `@NSManaged` property of the `NSManagedObject`. When the managed object is populated from
/// the database, the `model` property can be read to decode the data.
///
protocol CodableModelData: AnyObject, NSManagedObject {
associatedtype Model: Codable
/// A `@NSManaged` property of the manage object for storing the encoded model as data.
var modelData: Data? { get set }
}
extension CodableModelData {
/// Encodes or decodes the model to/from the data instance.
var model: Model? {
get {
guard let modelData else { return nil }
do {
return try JSONDecoder().decode(Model.self, from: modelData)
} catch {
Logger.bridgeKit.error("Error decoding \(String(describing: Model.self)): \(error)")
return nil
}
}
set {
guard let newValue else {
modelData = nil
return
}
do {
modelData = try JSONEncoder().encode(newValue)
} catch {
Logger.bridgeKit.error("Error encoding \(String(describing: Model.self)): \(error)")
}
}
}
}

View File

@ -0,0 +1,16 @@
/// A protocol for a service that can report non-fatal errors for investigation.
///
public protocol ErrorReporter: AnyObject {
// MARK: Properties
/// Whether collecting non-fatal errors and crash reports is enabled.
var isEnabled: Bool { get set }
// MARK: Methods
/// Logs an error to be reported.
///
/// - Parameter error: The error to log.
///
func log(error: Error)
}

View File

@ -0,0 +1,14 @@
import OSLog
public extension Logger {
// MARK: Type Properties
/// Logger instance for the app's action extension.
static let bridgeKit = Logger(subsystem: subsystem, category: "AuthenticatorBridgeKit")
// MARK: Private
/// The Logger subsystem passed along with logs to the logging system to identify logs from this
/// application.
private static let subsystem = Bundle.main.bundleIdentifier!
}

View File

@ -0,0 +1,78 @@
import CoreData
/// A protocol for an `NSManagedObject` data model that adds some convenience methods for working
/// with Core Data.
///
protocol ManagedObject: AnyObject {
/// The name of the entity of the managed object, as defined in the data model.
static var entityName: String { get }
}
extension ManagedObject where Self: NSManagedObject {
static var entityName: String {
String(describing: self)
}
/// Returns a `NSBatchInsertRequest` for batch inserting an array of objects.
///
/// - Parameters:
/// - objects: The objects (or objects that can be converted to managed objects) to insert.
/// - handler: A handler that is called for each object to set the properties on the
/// `NSManagedObject` to insert.
/// - Returns: A `NSBatchInsertRequest` for batch inserting an array of objects.
///
static func batchInsertRequest<T>(
objects: [T],
handler: @escaping (Self, T) throws -> Void
) throws -> NSBatchInsertRequest {
var index = 0
var errorToThrow: Error?
let insertRequest = NSBatchInsertRequest(entityName: entityName) { (managedObject: NSManagedObject) -> Bool in
guard index < objects.count else { return true }
defer { index += 1 }
if let managedObject = (managedObject as? Self) {
do {
try handler(managedObject, objects[index])
} catch {
// The error can't be thrown directly in this closure, so capture it, return
// from the closure, and then throw it.
errorToThrow = error
return true
}
}
return false
}
if let errorToThrow {
throw errorToThrow
}
return insertRequest
}
/// Returns a `NSFetchRequest` for fetching instances of the managed object.
///
/// - Parameter predicate: An optional predicate to apply to the fetch request.
/// - Returns: A `NSFetchRequest` used to fetch instances of the managed object.
///
static func fetchRequest(predicate: NSPredicate? = nil) -> NSFetchRequest<Self> {
let fetchRequest = NSFetchRequest<Self>(entityName: entityName)
fetchRequest.predicate = predicate
return fetchRequest
}
/// Returns a `NSFetchRequest` for fetching a generic `NSFetchRequestResult` instances of the
/// managed object.
///
/// - Parameter predicate: An optional predicate to apply to the fetch request.
/// - Returns: A `NSFetchRequest` used to fetch generic `NSFetchRequestResult` instances of the
/// managed object.
///
static func fetchResultRequest(predicate: NSPredicate? = nil) -> NSFetchRequest<NSFetchRequestResult> {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
fetchRequest.predicate = predicate
return fetchRequest
}
}

View File

@ -0,0 +1,74 @@
import CoreData
/// A protocol for a `ManagedObject` data model associated with a user that adds some convenience
/// methods for building `NSPersistentStoreRequest` for common CRUD operations.
///
protocol ManagedUserObject: ManagedObject {
/// The value type (struct) associated with the managed object that is persisted in the database.
associatedtype ValueType
/// Returns a `NSPredicate` used for filtering by a user's ID.
///
/// - Parameter userId: The user ID associated with the managed object.
///
static func userIdPredicate(userId: String) -> NSPredicate
/// Returns a `NSPredicate` used for filtering by a user and managed object ID.
///
/// - Parameter userId: The user ID associated with the managed object.
///
static func userIdAndIdPredicate(userId: String, id: String) -> NSPredicate
/// Updates the managed object from its associated value type object and user ID.
///
/// - Parameters:
/// - value: The value type object used to update the managed object.
/// - userId: The user ID associated with the object.
///
func update(with value: ValueType, userId: String) throws
}
extension ManagedUserObject where Self: NSManagedObject {
/// A `NSBatchInsertRequest` that inserts objects for the specified user.
///
/// - Parameters:
/// - objects: The list of objects to insert.
/// - userId: The user associated with the objects to insert.
/// - Returns: A `NSBatchInsertRequest` that inserts the objects for the user.
///
static func batchInsertRequest(objects: [ValueType], userId: String) throws -> NSBatchInsertRequest {
try batchInsertRequest(objects: objects) { object, value in
try object.update(with: value, userId: userId)
}
}
/// A `NSBatchDeleteRequest` that deletes all objects for the specified user.
///
/// - Parameter userId: The user associated with the objects to delete.
/// - Returns: A `NSBatchDeleteRequest` that deletes all objects for the user.
///
static func deleteByUserIdRequest(userId: String) -> NSBatchDeleteRequest {
let fetchRequest = fetchResultRequest(predicate: userIdPredicate(userId: userId))
return NSBatchDeleteRequest(fetchRequest: fetchRequest)
}
/// A `NSFetchRequest` that fetches objects for the specified user matching an ID.
///
/// - Parameters:
/// - id: The ID of the object to fetch.
/// - userId: The user associated with the object to fetch.
/// - Returns: A `NSFetchRequest` that fetches all objects for the user.
///
static func fetchByIdRequest(id: String, userId: String) -> NSFetchRequest<Self> {
fetchRequest(predicate: userIdAndIdPredicate(userId: userId, id: id))
}
/// A `NSFetchRequest` that fetches all objects for the specified user.
///
/// - Parameter userId: The user associated with the objects to delete.
/// - Returns: A `NSFetchRequest` that fetches all objects for the user.
///
static func fetchByUserIdRequest(userId: String) -> NSFetchRequest<Self> {
fetchRequest(predicate: userIdPredicate(userId: userId))
}
}

View File

@ -0,0 +1,52 @@
import CoreData
extension NSManagedObjectContext {
/// Executes the batch delete request and/or batch insert request and merges any changes into
/// the current context plus any additional contexts.
///
/// - Parameters:
/// - batchDeleteRequest: The batch delete request to execute.
/// - batchInsertRequest: The batch insert request to execute.
/// - additionalContexts: Any additional contexts other than the current to merge the changes into.
///
func executeAndMergeChanges(
batchDeleteRequest: NSBatchDeleteRequest? = nil,
batchInsertRequest: NSBatchInsertRequest? = nil,
additionalContexts: [NSManagedObjectContext] = []
) throws {
var changes: [AnyHashable: Any] = [:]
if let batchDeleteRequest {
batchDeleteRequest.resultType = .resultTypeObjectIDs
if let deleteResult = try execute(batchDeleteRequest) as? NSBatchDeleteResult {
changes[NSDeletedObjectsKey] = deleteResult.result as? [NSManagedObjectID] ?? []
}
}
if let batchInsertRequest {
batchInsertRequest.resultType = .objectIDs
if let insertResult = try execute(batchInsertRequest) as? NSBatchInsertResult {
changes[NSInsertedObjectsKey] = insertResult.result as? [NSManagedObjectID] ?? []
}
}
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self] + additionalContexts)
}
/// Performs the closure on the context's queue and saves the context if there are any changes.
///
/// - Parameter closure: The closure to perform.
///
func performAndSave(closure: @escaping () throws -> Void) async throws {
try await perform {
try closure()
try self.saveIfChanged()
}
}
/// Saves the context if there are changes.
func saveIfChanged() throws {
guard hasChanges else { return }
try save()
}
}

View File

@ -0,0 +1,127 @@
import Foundation
import XCTest
@testable import AuthenticatorBridgeKit
final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase {
// MARK: Properties
let accessGroup = "group.com.example.bitwarden-authenticator"
var dataStore: AuthenticatorBridgeDataStore!
var errorReporter: ErrorReporter!
var itemService: AuthenticatorBridgeItemService!
var subject: AuthenticatorBridgeItemData!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
errorReporter = MockErrorReporter()
dataStore = AuthenticatorBridgeDataStore(
errorReporter: errorReporter,
groupIdentifier: accessGroup,
storeType: .memory
)
itemService = DefaultAuthenticatorBridgeItemService(
dataStore: dataStore,
sharedKeychainRepository: MockSharedKeychainRepository()
)
}
override func tearDown() {
dataStore = nil
errorReporter = nil
subject = nil
super.tearDown()
}
// MARK: Tests
/// Verify that creating an `AuthenticatorBridgeItemData` succeeds and returns the expected modelData
/// correctly coded.
///
func test_init_success() async throws {
subject = try AuthenticatorBridgeItemData(
context: dataStore.persistentContainer.viewContext,
userId: "userId",
authenticatorItem: AuthenticatorBridgeItemDataModel(
favorite: true, id: "is", name: "name", totpKey: "TOTP Key", username: "username"
)
)
let modelData = try XCTUnwrap(subject.modelData)
let model = try JSONDecoder().decode(AuthenticatorBridgeItemDataModel.self, from: modelData)
XCTAssertEqual(subject.model, model)
}
/// Verify that the fetchById request correctly returns an empty list when no item matches the given userId and id.
///
func test_fetchByIdRequest_empty() async throws {
let expectedItems = AuthenticatorBridgeItemDataModel.fixtures()
try await itemService.insertItems(expectedItems, forUserId: "userId")
let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: "bad id", userId: "userId")
let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest)
XCTAssertNotNil(result)
XCTAssertEqual(result.count, 0)
}
/// Verify that the fetchById request correctly finds the item with the given userId and id.
///
func test_fetchByIdRequest_success() async throws {
let expectedItems = AuthenticatorBridgeItemDataModel.fixtures()
let expectedItem = expectedItems[3]
try await itemService.insertItems(expectedItems, forUserId: "userId")
let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: expectedItem.id, userId: "userId")
let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest)
XCTAssertNotNil(result)
XCTAssertEqual(result.count, 1)
let item = try XCTUnwrap(result.first?.model)
XCTAssertEqual(item, expectedItem)
}
/// Verify that the `fetchByUserIdRequest(userId:)` successfully returns an empty list when their are no
/// items for the given userId
///
func test_fetchByUserIdRequest_empty() async throws {
let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id }
try await itemService.insertItems(expectedItems, forUserId: "userId")
let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(
userId: "nonexistent userId"
)
let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest)
XCTAssertNotNil(result)
XCTAssertEqual(result.count, 0)
}
/// Verify that the `fetchByUserIdRequest(userId:)` successfully finds all of the data for a given
/// userId from the store. Verify that it does NOT return any data for a different userId
///
func test_fetchByUserIdRequest_success() async throws {
// Insert items for "userId"
let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id }
try await itemService.insertItems(expectedItems, forUserId: "userId")
// Separate Insert for "differentUserId"
let differentUserItem = AuthenticatorBridgeItemDataModel.fixture()
try await itemService.insertItems([differentUserItem], forUserId: "differentUserId")
// Verify items returned for "userId" do not contain items from "differentUserId"
let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: "userId")
let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest)
XCTAssertNotNil(result)
XCTAssertEqual(result.count, expectedItems.count)
// None of the items for userId should contain the item inserted for differentUserId
let emptyResult = result.filter { $0.id == differentUserItem.id }
XCTAssertEqual(emptyResult.count, 0)
}
}

View File

@ -0,0 +1,151 @@
import Foundation
import XCTest
@testable import AuthenticatorBridgeKit
final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase {
// MARK: Properties
let accessGroup = "group.com.example.bitwarden-authenticator"
var dataStore: AuthenticatorBridgeDataStore!
var errorReporter: ErrorReporter!
var keychainRepository: SharedKeychainRepository!
var subject: AuthenticatorBridgeItemService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
errorReporter = MockErrorReporter()
dataStore = AuthenticatorBridgeDataStore(
errorReporter: errorReporter,
groupIdentifier: accessGroup,
storeType: .memory
)
keychainRepository = MockSharedKeychainRepository()
subject = DefaultAuthenticatorBridgeItemService(
dataStore: dataStore,
sharedKeychainRepository: keychainRepository
)
}
override func tearDown() {
dataStore = nil
errorReporter = nil
keychainRepository = nil
subject = nil
super.tearDown()
}
// MARK: Tests
/// Verify that the `deleteAllForUserId` method successfully deletes all of the data for a given
/// userId from the store. Verify that it does NOT delete the data for a different userId
///
func test_deleteAllForUserId_success() async throws {
let items = AuthenticatorBridgeItemDataModel.fixtures()
// First Insert for "userId"
try await subject.insertItems(items, forUserId: "userId")
// Separate Insert for "differentUserId"
try await subject.insertItems(AuthenticatorBridgeItemDataModel.fixtures(),
forUserId: "differentUserId")
// Remove the items for "differentUserId"
try await subject.deleteAllForUserId("differentUserId")
// Verify items are removed for "differentUserId"
let deletedFetchResult = try await subject.fetchAllForUserId("differentUserId")
XCTAssertNotNil(deletedFetchResult)
XCTAssertEqual(deletedFetchResult.count, 0)
// Verify items are still present for "userId"
let result = try await subject.fetchAllForUserId("userId")
XCTAssertNotNil(result)
XCTAssertEqual(result.count, items.count)
}
/// Verify that the `fetchAllForUserId` method successfully fetches the data for the given user id, and does not
/// include data for a different user id.
///
func test_fetchAllForUserId_success() async throws {
// Insert items for "userId"
let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id }
try await subject.insertItems(expectedItems, forUserId: "userId")
// Separate Insert for "differentUserId"
let differentUserItem = AuthenticatorBridgeItemDataModel.fixture()
try await subject.insertItems([differentUserItem], forUserId: "differentUserId")
// Fetch should return only the expectedItem
let result = try await subject.fetchAllForUserId("userId")
XCTAssertNotNil(result)
XCTAssertEqual(result.count, expectedItems.count)
XCTAssertEqual(result, expectedItems)
// None of the items for userId should contain the item inserted for differentUserId
let emptyResult = result.filter { $0.id == differentUserItem.id }
XCTAssertEqual(emptyResult.count, 0)
}
/// Verify that the `insertItems(_:forUserId:)` method successfully inserts the list of items
/// for the given user id.
///
func test_insertItemsForUserId_success() async throws {
let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id }
try await subject.insertItems(expectedItems, forUserId: "userId")
let result = try await subject.fetchAllForUserId("userId")
XCTAssertEqual(result, expectedItems)
}
/// Verify the `replaceAllItems` correctly deletes all of the items in the store previously when given
/// an empty list of items to insert for the given userId.
///
func test_replaceAllItems_emptyInsertDeletesExisting() async throws {
// Insert initial items for "userId"
let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id }
try await subject.insertItems(expectedItems, forUserId: "userId")
// Replace with empty list, deleting all
try await subject.replaceAllItems(with: [], forUserId: "userId")
let result = try await subject.fetchAllForUserId("userId")
XCTAssertEqual(result, [])
}
/// Verify the `replaceAllItems` correctly replaces all of the items in the store previously with the new
/// list of items for the given userId
///
func test_replaceAllItems_replacesExisting() async throws {
// Insert initial items for "userId"
let initialItems = [AuthenticatorBridgeItemDataModel.fixture()]
try await subject.insertItems(initialItems, forUserId: "userId")
// Replace items for "userId"
let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id }
try await subject.replaceAllItems(with: expectedItems, forUserId: "userId")
let result = try await subject.fetchAllForUserId("userId")
XCTAssertEqual(result, expectedItems)
XCTAssertFalse(result.contains { $0 == initialItems.first })
}
/// Verify the `replaceAllItems` correctly inserts items when a userId doesn't contain any
/// items in the store previously.
///
func test_replaceAllItems_startingFromEmpty() async throws {
// Insert items for "userId"
let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id }
try await subject.replaceAllItems(with: expectedItems, forUserId: "userId")
let result = try await subject.fetchAllForUserId("userId")
XCTAssertEqual(result, expectedItems)
}
}

View File

@ -0,0 +1,24 @@
import CoreData
import XCTest
@testable import AuthenticatorBridgeKit
class ManagedObjectTests: AuthenticatorBridgeKitTestCase {
// MARK: Tests
/// `fetchRequest()` returns a `NSFetchRequest` for the entity.
func test_fetchRequest() {
let fetchRequest = TestManagedObject.fetchRequest()
XCTAssertEqual(fetchRequest.entityName, "TestManagedObject")
}
/// `fetchResultRequest()` returns a `NSFetchRequest` for the entity.
func test_fetchResultRequest() {
let fetchRequest = TestManagedObject.fetchResultRequest()
XCTAssertEqual(fetchRequest.entityName, "TestManagedObject")
}
}
private class TestManagedObject: NSManagedObject, ManagedObject {
static var entityName = "TestManagedObject"
}

View File

@ -0,0 +1,34 @@
import Foundation
@testable import AuthenticatorBridgeKit
extension AuthenticatorBridgeItemDataModel {
static func fixture(
favorite: Bool = false,
id: String = UUID().uuidString,
name: String = "Name",
totpKey: String? = nil,
username: String? = nil
) -> AuthenticatorBridgeItemDataModel {
AuthenticatorBridgeItemDataModel(
favorite: favorite,
id: id,
name: name,
totpKey: totpKey,
username: username
)
}
static func fixtures() -> [AuthenticatorBridgeItemDataModel] {
[
AuthenticatorBridgeItemDataModel.fixture(),
AuthenticatorBridgeItemDataModel.fixture(favorite: true),
AuthenticatorBridgeItemDataModel.fixture(totpKey: "TOTP Key"),
AuthenticatorBridgeItemDataModel.fixture(username: "Username"),
AuthenticatorBridgeItemDataModel.fixture(totpKey: "TOTP Key", username: "Username"),
AuthenticatorBridgeItemDataModel.fixture(totpKey: ""),
AuthenticatorBridgeItemDataModel.fixture(username: ""),
AuthenticatorBridgeItemDataModel.fixture(totpKey: "", username: ""),
]
}
}

View File

@ -0,0 +1,10 @@
@testable import AuthenticatorBridgeKit
class MockErrorReporter: ErrorReporter {
var errors = [Error]()
var isEnabled = false
func log(error: Error) {
errors.append(error)
}
}