From 52a5d968eb2d378b0ee063359d0a11f7df54e214 Mon Sep 17 00:00:00 2001 From: Jacob Hearst Date: Mon, 30 Sep 2024 07:20:30 -0500 Subject: [PATCH 1/3] Add KeychainQuerying subscripts to Ephemeral Strategy --- .../HaversackEphemeralStrategy.swift | 22 ++++++++++++++ .../EphemeralStrategyTests.swift | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/Sources/HaversackMock/HaversackEphemeralStrategy.swift b/Sources/HaversackMock/HaversackEphemeralStrategy.swift index e6845ef..f292d18 100644 --- a/Sources/HaversackMock/HaversackEphemeralStrategy.swift +++ b/Sources/HaversackMock/HaversackEphemeralStrategy.swift @@ -7,6 +7,9 @@ import Haversack /// A strategy which uses a simple dictionary to search, store, and delete data instead of hitting an actual keychain. /// /// The keys of the ``mockData`` dictionary are calculated from the queries that are sent through Haversack. +/// +/// You can also use either the untyped ``subscript(_:)-7weqp`` or the typed ``subscript(_:)-6jjzx`` +/// accessors to avoid having to hold onto the calculated keys. open class HaversackEphemeralStrategy: HaversackStrategy { /// The dictionary that is used for storage of keychain items /// @@ -38,6 +41,25 @@ open class HaversackEphemeralStrategy: HaversackStrategy { /// If the strategy has any problems it will throw `NSError` with this domain. public static let errorDomain = "haversack.unit_testing.mock" + /// Untyped access to ``mockData`` values via keychain queries instead of `String`s + /// - Parameter query: The keychain query to read/write to + /// - Returns: The ``mockData`` value for the `query` + public subscript(_ query: any KeychainQuerying) -> Any? { + get { + mockData[key(for: query.query)] + } + set { + mockData[key(for: query.query)] = newValue + } + } + + /// Typed access to ``mockData`` values via keychain queries instead of `String`s + /// - Parameter query: The keychain query to read the value for + /// - Returns: The ``mockData`` value for the `query` + public subscript(_ query: any KeychainQuerying) -> T? { + self[query] as? T + } + /// Looks through the ``mockData`` dictionary for an entry matching the query. /// - Parameter querying: An instance of a type that conforms to the `KeychainQuerying` protocol. /// - Throws: An `NSError` with the ``errorDomain`` domain if no entry is found in the dictionary. diff --git a/Tests/HaversackTests/EphemeralStrategyTests.swift b/Tests/HaversackTests/EphemeralStrategyTests.swift index 008f511..3cf818f 100644 --- a/Tests/HaversackTests/EphemeralStrategyTests.swift +++ b/Tests/HaversackTests/EphemeralStrategyTests.swift @@ -64,4 +64,33 @@ final class EphemeralStrategyTests: XCTestCase { XCTAssertNotNil(mock.certificateImportConfiguration) } #endif + + func testUntypedSubscript() throws { + // Given + let mock = HaversackEphemeralStrategy() + let query = GenericPasswordQuery() + let mockValue = "test" + + // When + mock[query] = mockValue + let storedAny = mock[query] + + // Then + let storedString = try XCTUnwrap(storedAny as? String) + XCTAssertEqual(storedString, mockValue) + } + + func testTypedSubscript() { + // Given + let mock = HaversackEphemeralStrategy() + let query = GenericPasswordQuery() + let mockValue = "test" + + // When + mock[query] = mockValue + let storedString: String? = mock[query] + + // Then + XCTAssertEqual(storedString, mockValue) + } } From 7a9a6b5ec347ffb2a1f59c9296120f3cb07ac178 Mon Sep 17 00:00:00 2001 From: Jacob Hearst Date: Fri, 18 Oct 2024 08:05:36 -0500 Subject: [PATCH 2/3] Replace subscripts with getters+setters --- CHANGELOG.md | 4 + Sources/Haversack/Haversack.swift | 335 ++++++++++-------- Sources/Haversack/HaversackStrategy.swift | 5 +- .../ImportExport/KeychainImportConfig.swift | 1 - .../HaversackEphemeralStrategy+mocking.swift | 156 ++++++++ .../HaversackEphemeralStrategy.swift | 31 +- .../EphemeralStrategyTests.swift | 161 +++++++-- 7 files changed, 473 insertions(+), 220 deletions(-) create mode 100644 Sources/HaversackMock/HaversackEphemeralStrategy+mocking.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 55dce3c..cffe713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased +### Added +- Added convenience methods for accessing mock data values on the `HaversackEphemeralStrategy` + ## [1.3.0] - 2024-02-11 ### Added - Added support for visionOS. diff --git a/Sources/Haversack/Haversack.swift b/Sources/Haversack/Haversack.swift index c2d5063..c23c5ec 100644 --- a/Sources/Haversack/Haversack.swift +++ b/Sources/Haversack/Haversack.swift @@ -25,7 +25,7 @@ public struct Haversack { /// - Returns: Something conforming to the ``KeychainStorable`` protocol, based on the query type. public func first(where query: T) throws -> T.Entity { return try configuration.serialQueue.sync { - let localQuery = try precheckSearch(query, singleItem: true) + let localQuery = try makeSearchQuery(query, singleItem: true) return try self.configuration.strategy.searchForOne(localQuery) } @@ -43,7 +43,7 @@ public struct Haversack { configuration.serialQueue.async { let result: Result do { - let localQuery = try precheckSearch(query, singleItem: true) + let localQuery = try makeSearchQuery(query, singleItem: true) let entity = try self.configuration.strategy.searchForOne(localQuery) result = .success(entity) @@ -61,8 +61,8 @@ public struct Haversack { /// - Throws: A ``HaversackError`` if the query returns no items or any errors occur. /// - Returns: An array of items conforming to the ``KeychainStorable`` protocol, based on the query type. public func search(where query: T) throws -> [T.Entity] { - return try configuration.serialQueue.sync { - let localQuery = try precheckSearch(query) + try configuration.serialQueue.sync { + let localQuery = try makeSearchQuery(query, singleItem: false) return try self.configuration.strategy.search(localQuery) } @@ -80,7 +80,7 @@ public struct Haversack { configuration.serialQueue.async { let result: Result<[T.Entity], Error> do { - let localQuery = try precheckSearch(query) + let localQuery = try makeSearchQuery(query, singleItem: true) let entities = try self.configuration.strategy.search(localQuery) result = .success(entities) @@ -104,8 +104,8 @@ public struct Haversack { /// - Returns: The original `item`. @discardableResult public func save(_ item: T, itemSecurity: ItemSecurity, updateExisting: Bool) throws -> T { - return try configuration.serialQueue.sync { - return try unsynchronizedSave(item, itemSecurity: itemSecurity, updateExisting: updateExisting) + try configuration.serialQueue.sync { + try unsynchronizedSave(item, itemSecurity: itemSecurity, updateExisting: updateExisting) } } @@ -136,6 +136,138 @@ public struct Haversack { } } + // MARK: - Deletion + + /// Synchronously delete an item from the keychain that was previously retrieved from the keychain. + /// + /// If the item does not include a `reference` previously retrieved from the keychain: on iOS/tvOS/visionOS/watchOS + /// all items matching the item metadata will be deleted, while on macOS only the first matching item will be deleted. + /// - Parameter item: The item retrieved from the keychain. + /// - Parameter treatNotFoundAsSuccess: If true, no error is thrown when the query does not + /// find an item to delete; default is true. + /// - Throws: A ``HaversackError`` object if any errors occur. + public func delete(_ item: T, treatNotFoundAsSuccess: Bool = true) throws { + try configuration.serialQueue.sync { + try self.unsynchronizedDelete(item, treatNotFoundAsSuccess: treatNotFoundAsSuccess) + } + } + + /// Aynchronously delete an item from the keychain that was previously retrieved from the keychain. + /// + /// If the item does not include a `reference` previously retrieved from the keychain: on iOS/tvOS/visionOS/watchOS + /// all items matching the item metadata will be deleted, while on macOS only the first matching item will be deleted. + /// - Parameters: + /// - item: The item retrieved from the keychain. + /// - treatNotFoundAsSuccess: If true, no error is thrown when the query does not find an + /// item to delete; default is true. + /// - completionQueue: The `completion` function will be called on this queue if given, or + /// the configuration's strategy's `serialQueue` if this is `nil`. + /// - completion: A function/block to be called when the delete operation is completed. + /// - error: If an error occurs during the delete operation it will be given to the completion + /// block; a `nil` represents no error. + public func delete(_ item: T, treatNotFoundAsSuccess: Bool = true, + completionQueue: OperationQueue? = nil, + completion: @escaping (_ error: Error?) -> Void) { + configuration.serialQueue.async { + let result: Error? + + do { + try self.unsynchronizedDelete(item, treatNotFoundAsSuccess: treatNotFoundAsSuccess) + result = nil + } catch { + result = error + } + + if let actualQueue = completionQueue { + actualQueue.addOperation { + completion(result) + } + } else { + completion(result) + } + } + } + + /// Synchronously delete one or more items from the keychain based on a search query. + /// - Parameter query: A Haversack query item + /// - Parameter treatNotFoundAsSuccess: If true, no error is thrown when the query does not + /// find an item to delete; default is true. + /// - Throws: A ``HaversackError`` object if any errors occur. + public func delete(where query: T, treatNotFoundAsSuccess: Bool = true) throws { + try configuration.serialQueue.sync { + let localQuery = try makeDeleteQuery(query) + try self.configuration.strategy.delete(localQuery.query, treatNotFoundAsSuccess: treatNotFoundAsSuccess) + } + } + + /// Asynchronously delete one or more items from the keychain based on a search query. + /// - Parameters: + /// - query: A Haversack search query. + /// - treatNotFoundAsSuccess: If true, no error is thrown when the query does not find + /// an item to delete; default is true. + /// - completionQueue: The `completion` function will be called on this queue if given, or + /// the configuration's strategy's `serialQueue` if this is `nil`. + /// - completion: A function/block to be called when the delete operation is completed. + /// - error: If an error occurs during the delete operation it will be given to the completion + /// block; a `nil` represents no error. + public func delete(where query: T, treatNotFoundAsSuccess: Bool = true, + completionQueue: OperationQueue? = nil, + completion: @escaping (_ error: Error?) -> Void) { + configuration.serialQueue.async { + let result: Error? + + do { + let localQuery = try makeDeleteQuery(query) + try self.configuration.strategy.delete(localQuery.query, treatNotFoundAsSuccess: treatNotFoundAsSuccess) + result = nil + } catch { + result = error + } + + if let actualQueue = completionQueue { + actualQueue.addOperation { + completion(result) + } + } else { + completion(result) + } + } + } + + // MARK: Importing/Exporting +#if os(macOS) + /// Export one or more certificates, identities, or keys from the keychain + /// - Parameters: + /// - entities: The entities to export + /// - config: A configuration representing all the options that can be provided to `SecItemExport` + /// - Returns: The exported data + public func exportItems(_ entities: [any KeychainExportable], config: KeychainExportConfig) throws -> Data { + try configuration.serialQueue.sync { + try configuration.strategy.exportItems(entities, configuration: config) + } + } + + /// Import one or more certificates, identities, or keys to the keychain + /// + /// - Parameters: + /// - data: The certificates, identities, or keys represented as `Data` + /// - config: A configuration representing all the options that can be provided to `SecItemImport` + /// - Returns: An array of all the items that were imported + public func importItems(_ data: Data, config: KeychainImportConfig) throws -> [EntityType] { + try configuration.serialQueue.sync { + guard + config.shouldImportIntoKeychain, + let actualKeychain = configuration.keychain + else { + return try configuration.strategy.importItems(data, configuration: config) + } + + try actualKeychain.attemptToOpen() + return try configuration.strategy.importItems(data, configuration: config, importKeychain: actualKeychain.reference) + } + } +#endif + // MARK: - Key generation /// Create a new asymmetric cryptographic key pair. @@ -325,13 +457,7 @@ public struct Haversack { } private func unsynchronizedSave(_ item: T, itemSecurity: ItemSecurity, updateExisting: Bool) throws -> T { - var query = self.merge(item: item, withSecurity: itemSecurity) - - #if os(macOS) - query = try self.addKeychain(to: query, forAdd: true) - #endif - - try self.precheck(query) + let query = try makeSaveQuery(item, itemSecurity: itemSecurity) if updateExisting { try unsynchronizedSave(query, deleteIfNeeded: item) @@ -344,170 +470,67 @@ public struct Haversack { private func unsynchronizedKeyGeneration(fromConfig config: KeyGenerationConfig, itemSecurity: ItemSecurity) throws -> SecKey { - var query = self.merge(keyConfig: config, withSecurity: itemSecurity) - - #if os(macOS) - query = try self.addKeychain(to: query, forAdd: true) - #endif - - try self.precheck(query) + let query = try makeKeyGenerationQuery(fromConfig: config, itemSecurity: itemSecurity) return try self.configuration.strategy.generateKey(query) } private func unsynchronizedDelete(_ item: T, treatNotFoundAsSuccess: Bool) throws { - var localQuery = item.entityQuery(includeSecureData: false) - - #if os(macOS) - localQuery = try self.addKeychain(to: localQuery, forAdd: false) - // iOS does not support kSecMatchLimit for delete operations - localQuery[kSecMatchLimit as String] = kSecMatchLimitOne - #endif + let localQuery = try makeDeleteQuery(item) try self.configuration.strategy.delete(localQuery, treatNotFoundAsSuccess: treatNotFoundAsSuccess) } +} - // MARK: - Deletion - - /// Synchronously delete an item from the keychain that was previously retrieved from the keychain. - /// - /// If the item does not include a `reference` previously retrieved from the keychain: on iOS/tvOS/visionOS/watchOS - /// all items matching the item metadata will be deleted, while on macOS only the first matching item will be deleted. - /// - Parameter item: The item retrieved from the keychain. - /// - Parameter treatNotFoundAsSuccess: If true, no error is thrown when the query does not - /// find an item to delete; default is true. - /// - Throws: A ``HaversackError`` object if any errors occur. - public func delete(_ item: T, treatNotFoundAsSuccess: Bool = true) throws { - return try configuration.serialQueue.sync { - try self.unsynchronizedDelete(item, treatNotFoundAsSuccess: treatNotFoundAsSuccess) - } +// MARK: Query builders +extension Haversack { + package func makeSearchQuery(_ query: T, singleItem: Bool) throws -> T { + try precheckSearch(query, singleItem: singleItem) } - /// Aynchronously delete an item from the keychain that was previously retrieved from the keychain. - /// - /// If the item does not include a `reference` previously retrieved from the keychain: on iOS/tvOS/visionOS/watchOS - /// all items matching the item metadata will be deleted, while on macOS only the first matching item will be deleted. - /// - Parameters: - /// - item: The item retrieved from the keychain. - /// - treatNotFoundAsSuccess: If true, no error is thrown when the query does not find an - /// item to delete; default is true. - /// - completionQueue: The `completion` function will be called on this queue if given, or - /// the configuration's strategy's `serialQueue` if this is `nil`. - /// - completion: A function/block to be called when the delete operation is completed. - /// - error: If an error occurs during the delete operation it will be given to the completion - /// block; a `nil` represents no error. - public func delete(_ item: T, treatNotFoundAsSuccess: Bool = true, - completionQueue: OperationQueue? = nil, - completion: @escaping (_ error: Error?) -> Void) { - configuration.serialQueue.async { - let result: Error? - - do { - try self.unsynchronizedDelete(item, treatNotFoundAsSuccess: treatNotFoundAsSuccess) - result = nil - } catch { - result = error - } + package func makeSaveQuery(_ item: T, itemSecurity: ItemSecurity) throws -> SecurityFrameworkQuery { + var query = self.merge(item: item, withSecurity: itemSecurity) - if let actualQueue = completionQueue { - actualQueue.addOperation { - completion(result) - } - } else { - completion(result) - } - } - } +#if os(macOS) + query = try self.addKeychain(to: query, forAdd: true) +#endif - /// Synchronously delete one or more items from the keychain based on a search query. - /// - Parameter query: A Haversack query item - /// - Parameter treatNotFoundAsSuccess: If true, no error is thrown when the query does not - /// find an item to delete; default is true. - /// - Throws: A ``HaversackError`` object if any errors occur. - public func delete(where query: T, treatNotFoundAsSuccess: Bool = true) throws { - try configuration.serialQueue.sync { - var localQuery: T - #if os(macOS) - localQuery = try self.addKeychain(to: query) - #else - localQuery = query - #endif + try self.precheck(query) - try self.configuration.strategy.delete(localQuery.query, treatNotFoundAsSuccess: treatNotFoundAsSuccess) - } + return query } - /// Asynchronously delete one or more items from the keychain based on a search query. - /// - Parameters: - /// - query: A Haversack search query. - /// - treatNotFoundAsSuccess: If true, no error is thrown when the query does not find - /// an item to delete; default is true. - /// - completionQueue: The `completion` function will be called on this queue if given, or - /// the configuration's strategy's `serialQueue` if this is `nil`. - /// - completion: A function/block to be called when the delete operation is completed. - /// - error: If an error occurs during the delete operation it will be given to the completion - /// block; a `nil` represents no error. - public func delete(where query: T, treatNotFoundAsSuccess: Bool = true, - completionQueue: OperationQueue? = nil, - completion: @escaping (_ error: Error?) -> Void) { - configuration.serialQueue.async { - let result: Error? - - do { - var localQuery: T - #if os(macOS) - localQuery = try self.addKeychain(to: query) - #else - localQuery = query - #endif + package func makeDeleteQuery(_ item: T) throws -> SecurityFrameworkQuery { + var result = item.entityQuery(includeSecureData: false) - try self.configuration.strategy.delete(localQuery.query, treatNotFoundAsSuccess: treatNotFoundAsSuccess) - result = nil - } catch { - result = error - } +#if os(macOS) + result = try self.addKeychain(to: result, forAdd: false) + // iOS does not support kSecMatchLimit for delete operations + result[kSecMatchLimit as String] = kSecMatchLimitOne +#endif - if let actualQueue = completionQueue { - actualQueue.addOperation { - completion(result) - } - } else { - completion(result) - } - } + return result } - // MARK: Importing/Exporting - #if os(macOS) - /// Export one or more certificates, identities, or keys from the keychain - /// - Parameters: - /// - entity: The entities to export - /// - config: A configuration representing all the options that can be provided to `SecItemExport` - /// - Returns: The exported data - public func exportItems(_ entities: [any KeychainExportable], config: KeychainExportConfig) throws -> Data { - try configuration.serialQueue.sync { - try configuration.strategy.exportItems(entities, configuration: config) - } + package func makeDeleteQuery(_ query: T) throws -> T { + var localQuery: T +#if os(macOS) + localQuery = try self.addKeychain(to: query) +#else + localQuery = query +#endif + return localQuery } - /// Import one or more certificates, identities, or keys to the keychain - /// - /// - Parameters: - /// - data: The certificates, identities, or keys represented as `Data` - /// - config: A configuration representing all the options that can be provided to `SecItemImport` - /// - Returns: An array of all the items that were imported - public func importItems(_ data: Data, config: KeychainImportConfig) throws -> [EntityType] { - try configuration.serialQueue.sync { - guard - config.shouldImportIntoKeychain, - let actualKeychain = configuration.keychain - else { - return try configuration.strategy.importItems(data, configuration: config) - } + package func makeKeyGenerationQuery(fromConfig config: KeyGenerationConfig, itemSecurity: ItemSecurity) throws -> SecurityFrameworkQuery { + var query = self.merge(keyConfig: config, withSecurity: itemSecurity) - try actualKeychain.attemptToOpen() - return try configuration.strategy.importItems(data, configuration: config, importKeychain: actualKeychain.reference) - } +#if os(macOS) + query = try self.addKeychain(to: query, forAdd: true) +#endif + + try self.precheck(query) + + return query } - #endif } diff --git a/Sources/Haversack/HaversackStrategy.swift b/Sources/Haversack/HaversackStrategy.swift index cdb7d63..b4c39a6 100644 --- a/Sources/Haversack/HaversackStrategy.swift +++ b/Sources/Haversack/HaversackStrategy.swift @@ -202,7 +202,7 @@ open class HaversackStrategy { /// /// This should not be called directly but is used by ``Haversack/Haversack/exportItems(_:config:)`` to perform the actual exporting /// - Parameters: - /// - item: The keys, certificates, or identities to export + /// - items: The keys, certificates, or identities to export /// - configuration: A configuration representing all the options that can be provided to `SecItemExport` /// - Returns: A `Data` representation of the keychain item open func exportItems(_ items: [any KeychainExportable], configuration: KeychainExportConfig) throws -> Data { @@ -232,8 +232,9 @@ open class HaversackStrategy { /// Imports one or more keys, certificates, or identities and adds them to the keychain /// - Parameters: - /// - item: The keys, certificates, or identities to import + /// - items: The keys, certificates, or identities to import /// - configuration: A configuration representing all the options that can be provided to `SecItemImport` + /// - importKeychain: The keychain to import the items to /// - Returns: An array of all the keychain items imported open func importItems(_ items: Data, configuration: KeychainImportConfig, importKeychain: SecKeychain? = nil) throws -> [EntityType] { var inputFormat = configuration.inputFormat diff --git a/Sources/Haversack/ImportExport/KeychainImportConfig.swift b/Sources/Haversack/ImportExport/KeychainImportConfig.swift index 5b8472a..06c65d1 100644 --- a/Sources/Haversack/ImportExport/KeychainImportConfig.swift +++ b/Sources/Haversack/ImportExport/KeychainImportConfig.swift @@ -122,7 +122,6 @@ public struct KeychainImportConfig { } /// Make any imported keys extractable. By default keys are not extractable after import - /// - Parameter extractable: Whether or not the keychain item can be exported from its keychain (Defaults to false) /// - Returns: A `KeychainImportConfig` struct public func extractable() throws -> Self where T: PrivateKeyImporting { // swiftlint:disable:next line_length diff --git a/Sources/HaversackMock/HaversackEphemeralStrategy+mocking.swift b/Sources/HaversackMock/HaversackEphemeralStrategy+mocking.swift new file mode 100644 index 0000000..80f1783 --- /dev/null +++ b/Sources/HaversackMock/HaversackEphemeralStrategy+mocking.swift @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +// Copyright 2023, Jamf + +import Haversack +import Security +import XCTest + +extension Haversack { + /// A convenience accessor that handles typecasting the `HaversackStrategy` to a `HaversackEphemeralStrategy` + /// via XCTest's `XCTUnwrap` function. + var ephemeralStrategy: HaversackEphemeralStrategy { + get throws { + try XCTUnwrap(configuration.strategy as? HaversackEphemeralStrategy) + } + } +} + +// MARK: Mock data setters +extension Haversack { + /// Mocks data for calls to `Haversack.first(where:)` + /// - Parameters: + /// - query: The query to set a mock value for + /// - mockValue: The mock value to set + public func setSearchFirstMock(where query: T, mockValue: T.Entity) throws { + let query = try makeSearchQuery(query, singleItem: true) + try ephemeralStrategy.setMock(mockValue, forQuery: query.query) + } + + /// Retrieves the value for a call to `Haversack.first(where:)` from ``HaversackEphemeralStrategy/mockData`` + /// - Parameter query: The query to retrieve a value for + /// - Returns: The value associated with `query` in ``HaversackEphemeralStrategy/mockData`` + public func getSearchFirstMock(where query: T) throws -> T.Entity? { + let query = try makeSearchQuery(query, singleItem: true) + return try ephemeralStrategy.getMockDataValue(for: query.query) + } + + /// Mocks data for calls to `Haversack.search(where:)` + /// - Parameters: + /// - query: The query to set a mock value for + /// - mockValue: The mock value to set + public func setSearchMock(where query: T, mockValue: [T.Entity]) throws { + let query = try makeSearchQuery(query, singleItem: false) + try ephemeralStrategy.setMock(mockValue, forQuery: query.query) + } + + /// Retrieves the value for a call to `Haversack.search(where:)` from ``HaversackEphemeralStrategy/mockData`` + /// - Parameter query: The query to retrieve a value for + /// - Returns: The value associated with `query` in ``HaversackEphemeralStrategy/mockData`` + public func getSearchMock(where query: T) throws -> [T.Entity]? { + let query = try makeSearchQuery(query, singleItem: false) + return try ephemeralStrategy.getMockDataValue(for: query.query) + } + + /// Mocks data for calls to `Haversack.save(_:itemSecurity:updateExisting:)`. This is useful when + /// you want to test the behavior of your code when the item being saved already exists in the keychain. + /// - Parameters: + /// - item: The item being saved + /// - itemSecurity: The security the item should have + /// - mockValue: The mock value to set + public func setSaveMock(item: T, itemSecurity: ItemSecurity = .standard, mockValue: Any) throws { + let query = try makeSaveQuery(item, itemSecurity: itemSecurity) + try ephemeralStrategy.setMock(mockValue, forQuery: query) + } + + /// Retrieves the value for a call to `Haversack.save(_:itemSecurity:updateExisting:)` from ``HaversackEphemeralStrategy/mockData`` + /// - Parameters: + /// - item: The item being saved + /// - itemSecurity: The security the item should have + /// - Returns: The value associated with `query` in ``HaversackEphemeralStrategy/mockData`` + public func getSaveMock(item: T, itemSecurity: ItemSecurity = .standard) throws -> Any? { + let query = try makeSaveQuery(item, itemSecurity: itemSecurity) + return try ephemeralStrategy.getMockDataValue(for: query) + } + + /// Mocks data for calls to `Haversack.delete(_:treatNotFoundAsSuccess:)` + /// - Parameters: + /// - item: The item to generate a delete query and set a mock value for + /// - mockValue: The mock value to set + public func setDeleteMock(item: T, mockValue: Any) throws { + let query = try makeDeleteQuery(item) + try ephemeralStrategy.setMock(mockValue, forQuery: query) + } + + /// Retrieves the value for a call to `Haversack.delete(_:treatNotFoundAsSuccess:)` from ``HaversackEphemeralStrategy/mockData`` + /// - Parameter item: The item to generate a delete query and set a mock value for + /// - Returns: The value associated with `query` in ``HaversackEphemeralStrategy/mockData`` + public func getDeleteMock(item: T) throws -> Any? { + let query = try makeDeleteQuery(item) + return try ephemeralStrategy.getMockDataValue(for: query) + } + + /// Mocks data for calls to `Haversack.delete(where:treatNotFoundAsSuccess:)` + /// - Parameters: + /// - query: The query to set a mock value for + /// - mockValue: The mock value to set + public func setDeleteMock(where query: T, mockValue: Any) throws { + let query = try makeDeleteQuery(query) + try ephemeralStrategy.setMock(mockValue, forQuery: query.query) + } + + /// Retrieves the value for a call to `Haversack.delete(where:treatNotFoundAsSuccess:)` from ``HaversackEphemeralStrategy/mockData`` + /// - Parameter query: The query to retrieve a value for + /// - Returns: The value associated with `query` in ``HaversackEphemeralStrategy/mockData`` + public func getDeleteMock(where query: T) throws -> Any? { + let query = try makeDeleteQuery(query) + return try ephemeralStrategy.getMockDataValue(for: query.query) + } + + /// Mocks data for calls to `Haversack.generateKey(fromConfig:itemSecurity:)` + /// - Parameters: + /// - config: The key generation configuration values that the query should include + /// - itemSecurity: The item security the query should specify + /// - mockValue: The mock value to set + public func setGenerateKeyMock(config: KeyGenerationConfig, itemSecurity: ItemSecurity = .standard, mockValue: SecKey) throws { + let query = try makeKeyGenerationQuery(fromConfig: config, itemSecurity: itemSecurity) + try ephemeralStrategy.setMock(mockValue, forQuery: query) + } + + /// Retrieves the value for a call to `Haversack.generateKey(fromConfig:itemSecurity:)` from ``HaversackEphemeralStrategy/mockData`` + /// - Parameters: + /// - config: The key generation configuration values that the query should include + /// - itemSecurity: The item security the query should specify + /// - Returns: The value associated with `query` in ``HaversackEphemeralStrategy/mockData`` + public func getGenerateKeyMock(config: KeyGenerationConfig, itemSecurity: ItemSecurity = .standard) throws -> SecKey? { + let query = try makeKeyGenerationQuery(fromConfig: config, itemSecurity: itemSecurity) + return try ephemeralStrategy.getMockDataValue(for: query) + } +} + +extension HaversackEphemeralStrategy { + /// Generates a ``mockData`` key for the query and sets the value of that key to `mockValue` + /// - Parameters: + /// - mockValue: The value to mock + /// - query: The query that the mock value is associated with + func setMock(_ mockValue: Any, forQuery query: SecurityFrameworkQuery) { + mockData[key(for: query)] = mockValue + } + + /// Retrieves the ``mockData`` value for the provided query + /// + /// This overload is required because `Optional` can be typecast to `Any`. + /// This means that if the generic version of this function were called where `T == Any`, + /// it would always return a non-nil value of `Any` with the actual type of `Optional.none`. + /// - Parameter query: The query to retreive a value for + /// - Returns: The value + func getMockDataValue(for query: SecurityFrameworkQuery) -> Any? { + mockData[key(for: query)] + } + + /// Retrieves and typecasts the ``mockData`` value for the provided query + /// - Parameter query: The query to retreive a value for + /// - Returns: The typecasted value + func getMockDataValue(for query: SecurityFrameworkQuery) -> T? { + getMockDataValue(for: query) as? T + } +} diff --git a/Sources/HaversackMock/HaversackEphemeralStrategy.swift b/Sources/HaversackMock/HaversackEphemeralStrategy.swift index f292d18..591e3ca 100644 --- a/Sources/HaversackMock/HaversackEphemeralStrategy.swift +++ b/Sources/HaversackMock/HaversackEphemeralStrategy.swift @@ -4,12 +4,9 @@ import Foundation import Haversack -/// A strategy which uses a simple dictionary to search, store, and delete data instead of hitting an actual keychain. +/// A strategy which uses a simple dictionary to import, export, search, store, and delete data instead of hitting an actual keychain. /// /// The keys of the ``mockData`` dictionary are calculated from the queries that are sent through Haversack. -/// -/// You can also use either the untyped ``subscript(_:)-7weqp`` or the typed ``subscript(_:)-6jjzx`` -/// accessors to avoid having to hold onto the calculated keys. open class HaversackEphemeralStrategy: HaversackStrategy { /// The dictionary that is used for storage of keychain items /// @@ -41,25 +38,6 @@ open class HaversackEphemeralStrategy: HaversackStrategy { /// If the strategy has any problems it will throw `NSError` with this domain. public static let errorDomain = "haversack.unit_testing.mock" - /// Untyped access to ``mockData`` values via keychain queries instead of `String`s - /// - Parameter query: The keychain query to read/write to - /// - Returns: The ``mockData`` value for the `query` - public subscript(_ query: any KeychainQuerying) -> Any? { - get { - mockData[key(for: query.query)] - } - set { - mockData[key(for: query.query)] = newValue - } - } - - /// Typed access to ``mockData`` values via keychain queries instead of `String`s - /// - Parameter query: The keychain query to read the value for - /// - Returns: The ``mockData`` value for the `query` - public subscript(_ query: any KeychainQuerying) -> T? { - self[query] as? T - } - /// Looks through the ``mockData`` dictionary for an entry matching the query. /// - Parameter querying: An instance of a type that conforms to the `KeychainQuerying` protocol. /// - Throws: An `NSError` with the ``errorDomain`` domain if no entry is found in the dictionary. @@ -117,6 +95,7 @@ open class HaversackEphemeralStrategy: HaversackStrategy { /// - Parameter query: An instance of a `Haversack/SecurityFrameworkQuery`. /// - Returns: Returns the private key of a new cryptographic key pair. /// - Throws: An `NSError` object if the key cannot be found. Prior to throwing, also stores the query in the ``mockData`` for future inspection. + /// - Important: The mock value must be an instance of `SecKey` override open func generateKey(_ query: SecurityFrameworkQuery) throws -> SecKey { let theKey = key(for: query) @@ -159,7 +138,11 @@ open class HaversackEphemeralStrategy: HaversackStrategy { /// - Returns: The items in ``mockImportedEntities`` /// - Throws: Either ``mockImportError`` or an `NSError` with the ``errorDomain`` domain if the /// items in ``mockImportedEntities`` don't match the type of the `EntityType` of the `configuration` parameter. - override open func importItems(_ items: Data, configuration: KeychainImportConfig, importKeychain: SecKeychain? = nil) throws -> [EntityType] { + override open func importItems( + _ items: Data, + configuration: KeychainImportConfig, + importKeychain: SecKeychain? = nil + ) throws -> [EntityType] { if let keyImportConfig = configuration as? KeychainImportConfig { keyImportConfiguration = keyImportConfig } else if let certificateImportConfig = configuration as? KeychainImportConfig { diff --git a/Tests/HaversackTests/EphemeralStrategyTests.swift b/Tests/HaversackTests/EphemeralStrategyTests.swift index 3cf818f..4d1997e 100644 --- a/Tests/HaversackTests/EphemeralStrategyTests.swift +++ b/Tests/HaversackTests/EphemeralStrategyTests.swift @@ -6,23 +6,28 @@ import Haversack import HaversackMock final class EphemeralStrategyTests: XCTestCase { - func testInternetPWSearchReferenceMock() throws { - // given - let mock = HaversackEphemeralStrategy() - let expectedEntity = InternetPasswordEntity() - expectedEntity.protocol = .appleTalk - mock.mockData["acctlukeclassinetm_Limitm_Lir_Refsrvrstas"] = expectedEntity - let config = HaversackConfiguration(strategy: mock) - let haversack = Haversack(configuration: config) + var ephemeralStrategy: HaversackEphemeralStrategy! + var haversack: Haversack! + override func setUp() { + ephemeralStrategy = HaversackEphemeralStrategy() + haversack = Haversack(configuration: .init(strategy: ephemeralStrategy)) + } + + func testInternetPWSearchReferenceMock() throws { + // Given let pwQuery = InternetPasswordQuery(server: "stash") .matching(account: "luke") .returning(.reference) - // when + let expectedEntity = InternetPasswordEntity() + expectedEntity.protocol = .appleTalk + try haversack.setSearchFirstMock(where: pwQuery, mockValue: expectedEntity) + + // When let password = try haversack.first(where: pwQuery) - // then + // Then XCTAssertNotNil(password) XCTAssertEqual(password.protocol, .appleTalk) } @@ -30,16 +35,14 @@ final class EphemeralStrategyTests: XCTestCase { func testGenericPWSearchReferenceMock() throws { // given let testService = "unit.test" - let mock = HaversackEphemeralStrategy() - let expectedEntity = GenericPasswordEntity() - expectedEntity.service = testService - mock.mockData["classgenpm_Limitm_Lir_Refsvceunit"] = expectedEntity - let config = HaversackConfiguration(strategy: mock) - let haversack = Haversack(configuration: config) let pwQuery = GenericPasswordQuery(service: testService) .returning(.reference) + let expectedEntity = GenericPasswordEntity() + expectedEntity.service = testService + try haversack.setSearchFirstMock(where: pwQuery, mockValue: expectedEntity) + // when let password = try haversack.first(where: pwQuery) @@ -51,46 +54,130 @@ final class EphemeralStrategyTests: XCTestCase { #if os(macOS) func testImportItemsIncorrectMockEntities() throws { // Given - let mock = HaversackEphemeralStrategy() - mock.mockImportedEntities = [ KeyEntity() ] + ephemeralStrategy.mockImportedEntities = [ KeyEntity() ] - let haversack = Haversack(configuration: HaversackConfiguration(strategy: mock)) let importConfig = KeychainImportConfig() // When XCTAssertThrowsError(try haversack.importItems(Data(), config: importConfig)) // Then - XCTAssertNotNil(mock.certificateImportConfiguration) + XCTAssertNotNil(ephemeralStrategy.certificateImportConfiguration) } #endif - func testUntypedSubscript() throws { + // MARK: Mocking tests + + func testSetSearchFirstMock() throws { // Given - let mock = HaversackEphemeralStrategy() - let query = GenericPasswordQuery() - let mockValue = "test" + let testService = "unit.test" + let pwQuery = GenericPasswordQuery(service: testService) + .returning(.reference) + + let expectedEntity = GenericPasswordEntity() + expectedEntity.service = testService + try haversack.setSearchFirstMock(where: pwQuery, mockValue: expectedEntity) // When - mock[query] = mockValue - let storedAny = mock[query] + let password = try haversack.first(where: pwQuery) - // Then - let storedString = try XCTUnwrap(storedAny as? String) - XCTAssertEqual(storedString, mockValue) + // Then no error should be thrown + + // And the returned value should match the data that was set + XCTAssertNotNil(password) + XCTAssertEqual(password.service, testService) + } + + func testSetSearchMock() throws { + // Given + let testService = "unit.test" + let pwQuery = GenericPasswordQuery(service: testService) + .returning(.reference) + + let expectedEntity = GenericPasswordEntity() + expectedEntity.service = testService + try haversack.setSearchMock(where: pwQuery, mockValue: [expectedEntity]) + + // When + let passwords = try haversack.search(where: pwQuery) + + // Then no error should be thrown + + // And the returned value should match the data that was set + let password = try XCTUnwrap(passwords.first) + XCTAssertEqual(password.service, testService) + } + + func testSetSaveMock() throws { + // Given + let mockEntity = GenericPasswordEntity() + let testService = "unit.test" + mockEntity.service = testService + + try haversack.setSaveMock(item: mockEntity, itemSecurity: .standard, mockValue: mockEntity) + + do { + // When + try haversack.save(mockEntity, itemSecurity: .standard, updateExisting: false) + } catch let error as HaversackError { + // Then attempting to save a value with updateExisting=false should throw + XCTAssertEqual(error, HaversackError.keychainError(errSecDuplicateItem)) + } + } + + func testSetDeleteWhereMock() throws { + // Given + let pwQuery = GenericPasswordQuery() + .returning(.reference) + + try haversack.setDeleteMock(where: pwQuery, mockValue: "Any value") + + // When + try haversack.delete(where: pwQuery, treatNotFoundAsSuccess: false) + + // Then no error should be thrown + + // And the value should have been deleted + XCTAssertNil(try haversack.getDeleteMock(where: pwQuery)) } - func testTypedSubscript() { + func testSetDeleteItemMock() throws { // Given - let mock = HaversackEphemeralStrategy() - let query = GenericPasswordQuery() - let mockValue = "test" + let entity = GenericPasswordEntity() + try haversack.setDeleteMock(item: entity, mockValue: "Any value") // When - mock[query] = mockValue - let storedString: String? = mock[query] + try haversack.delete(entity, treatNotFoundAsSuccess: false) - // Then - XCTAssertEqual(storedString, mockValue) + // Then no error should be thrown + + // And the value should have been deleted + XCTAssertNil(try haversack.getDeleteMock(item: entity)) + } + + func testSetGenerateKeyMock() throws { + func loadKey() throws -> SecKey { + let data = try Data(contentsOf: getURLForTestResource(named: "key.bsafe")) + let config = try KeychainImportConfig() + .inputFormat(.formatBSAFE) + .returnEntitiesWithoutSaving() + + let importedEntities = try Haversack().importItems(data, config: config) + return try XCTUnwrap(importedEntities.first?.reference) + } + + // Given + let testKey = try loadKey() + let keyGenerationConfig = KeyGenerationConfig(algorithm: .RSA, keySize: 2048) + try haversack.setGenerateKeyMock(config: keyGenerationConfig, mockValue: testKey) + + // When + let key = try haversack.generateKey(fromConfig: keyGenerationConfig, itemSecurity: .standard) + + // Then no error should be thrown + + // And the value in `mockData` should be unchanged + let mockDataValue = try haversack.getGenerateKeyMock(config: keyGenerationConfig) + XCTAssertEqual(mockDataValue, key) } } From 5a63260ce5526c1e84c5221f332a8aa8f8d62c67 Mon Sep 17 00:00:00 2001 From: Jacob Hearst Date: Mon, 21 Oct 2024 09:35:55 -0500 Subject: [PATCH 3/3] Fix non macOS compilation --- Tests/HaversackTests/EphemeralStrategyTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/HaversackTests/EphemeralStrategyTests.swift b/Tests/HaversackTests/EphemeralStrategyTests.swift index 4d1997e..1ed23cc 100644 --- a/Tests/HaversackTests/EphemeralStrategyTests.swift +++ b/Tests/HaversackTests/EphemeralStrategyTests.swift @@ -155,6 +155,7 @@ final class EphemeralStrategyTests: XCTestCase { XCTAssertNil(try haversack.getDeleteMock(item: entity)) } + #if os(macOS) func testSetGenerateKeyMock() throws { func loadKey() throws -> SecKey { let data = try Data(contentsOf: getURLForTestResource(named: "key.bsafe")) @@ -180,4 +181,5 @@ final class EphemeralStrategyTests: XCTestCase { let mockDataValue = try haversack.getGenerateKeyMock(config: keyGenerationConfig) XCTAssertEqual(mockDataValue, key) } + #endif }