mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 00:42:29 -06:00
[BITAU-134] [BITAU-121] Create Shared CoreData Store (#937)
This commit is contained in:
parent
235f340f67
commit
fe37cb6297
135
AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift
Normal file
135
AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift
Normal 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]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
80
AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift
Normal file
80
AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
115
AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift
Normal file
115
AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
40
AuthenticatorBridgeKit/CodableModelData.swift
Normal file
40
AuthenticatorBridgeKit/CodableModelData.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
AuthenticatorBridgeKit/ErrorReporter.swift
Normal file
16
AuthenticatorBridgeKit/ErrorReporter.swift
Normal 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)
|
||||
}
|
||||
14
AuthenticatorBridgeKit/Logger+AuthenticatorBridgeKit.swift
Normal file
14
AuthenticatorBridgeKit/Logger+AuthenticatorBridgeKit.swift
Normal 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!
|
||||
}
|
||||
78
AuthenticatorBridgeKit/ManagedObject.swift
Normal file
78
AuthenticatorBridgeKit/ManagedObject.swift
Normal 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
|
||||
}
|
||||
}
|
||||
74
AuthenticatorBridgeKit/ManagedUserObject.swift
Normal file
74
AuthenticatorBridgeKit/ManagedUserObject.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
24
AuthenticatorBridgeKit/Tests/ManagedObjectTests.swift
Normal file
24
AuthenticatorBridgeKit/Tests/ManagedObjectTests.swift
Normal 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"
|
||||
}
|
||||
@ -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: ""),
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
@testable import AuthenticatorBridgeKit
|
||||
|
||||
class MockErrorReporter: ErrorReporter {
|
||||
var errors = [Error]()
|
||||
var isEnabled = false
|
||||
|
||||
func log(error: Error) {
|
||||
errors.append(error)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user