diff --git a/Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift b/Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift new file mode 100644 index 0000000..11fc43f --- /dev/null +++ b/Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift @@ -0,0 +1,84 @@ +// +// KeychainStorage.swift +// KeychainStorage +// +// Created by Javier Cicchelli on 11/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import Foundation +import KeychainAccess +import SwiftUI + +@propertyWrapper +public struct KeychainStorage: DynamicProperty { + + // MARK: Type aliases + + public typealias Value = Model + + // MARK: States + + @State private var value: Value? + + // MARK: Properties + + private let key: String + private let keychain: Keychainable + private let decoder: JSONDecoder = .init() + private let encoder: JSONEncoder = .init() + + public var wrappedValue: Value? { + get { value } + nonmutating set { + value = newValue + + do { + if let newValue { + let encodedValue = try encoder.encode(newValue) + + try keychain.set(encodedValue, key: key, ignoringAttributeSynchronizable: true) + } else { + try keychain.remove(key, ignoringAttributeSynchronizable: true) + } + } catch { + assertionFailure("The '\(key)' key should have either be set or removed from the keychain storage.") + } + } + } + + public var projectedValue: Binding { + .init { wrappedValue } set: { wrappedValue = $0 } + } + + // MARK: Initialisers + + public init( + key: String, + defaultValue: Value? = nil, + keychain: Keychainable = Keychain() + ) { + self.key = key + self.keychain = keychain + + do { + let data = try keychain.getData(key, ignoringAttributeSynchronizable: true) + + guard let data else { + assertionFailure("The data of the '\(key)' key should have been obtained from the keychain storage.") + return + } + + do { + let model = try decoder.decode(Value.self, from: data) + + self._value = .init(initialValue: model) + } catch { + assertionFailure("The data for the '\(key)' key should have been decoded properly.") + } + } catch { + self._value = .init(initialValue: defaultValue) + } + } + +} diff --git a/Libraries/Tests/KeychainStorageTests/Cases/Property Wrappers/KeychainStorage+InitTests.swift b/Libraries/Tests/KeychainStorageTests/Cases/Property Wrappers/KeychainStorage+InitTests.swift new file mode 100644 index 0000000..5a4d719 --- /dev/null +++ b/Libraries/Tests/KeychainStorageTests/Cases/Property Wrappers/KeychainStorage+InitTests.swift @@ -0,0 +1,105 @@ +// +// KeychainStorage+InitTests.swift +// KeychainStorageTests +// +// Created by Javier Cicchelli on 11/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import Foundation +import KeychainStorage +import XCTest + +final class KeychainStorageInitTests: XCTestCase { + + // MARK: Properties + + private var value: TestModel? + + // MARK: Test cases + + func testValue_whenNoDefaultValue_andEmptyStorage() throws { + // GIVEN + let keychainStorage = TestKeychainStorage_withNoDefaultValue_andEmptyStorage() + + // WHEN + value = keychainStorage.keychainValue + + // THEN + XCTAssertNil(value) + } + + func testValue_whenDefaultValue_andEmptyStorage() throws { + // GIVEN + let keychainStorage = TestKeychainStorage_withDefaultValue_andEmptyStorage() + + // WHEN + value = keychainStorage.keychainValue + + // THEN + XCTAssertNotNil(value) + } + + func testValue_whenNoDefaultValue_andValueInStorage() throws { + // GIVEN + let keychainStorage = TestKeychainStorage_withNoDefaultValue_andValueInStorage() + + // WHEN + value = keychainStorage.keychainValue + + // THEN + XCTAssertNotNil(value) + } + + func testValue_whenNoDefaultValue_andNoValueInStorage() throws { + // GIVEN + let keychainStorage = TestKeychainStorage_withNoDefaultValue_andNoValueInStorage() + + // WHEN + value = keychainStorage.keychainValue + + // THEN + XCTAssertNil(value) + } + +} + +// MARK: - Test classes + +private final class TestKeychainStorage_withNoDefaultValue_andEmptyStorage { + @KeychainStorage( + key: .Keys.someKey, + keychain: KeychainStorageMock() + ) + var keychainValue: TestModel? +} + + +private final class TestKeychainStorage_withDefaultValue_andEmptyStorage { + @KeychainStorage( + key: .Keys.someKey, + defaultValue: TestModel(), + keychain: KeychainStorageMock() + ) + var keychainValue: TestModel? +} + +private final class TestKeychainStorage_withNoDefaultValue_andValueInStorage { + @KeychainStorage( + key: .Keys.someKey, + keychain: KeychainStorageMock(storage: [ + .Keys.someKey: try! JSONEncoder().encode(TestModel()) + ]) + ) + var keychainValue: TestModel? +} + +private final class TestKeychainStorage_withNoDefaultValue_andNoValueInStorage { + @KeychainStorage( + key: .Keys.someKey, + keychain: KeychainStorageMock(storage: [ + .Keys.someOtherKey: try! JSONEncoder().encode(TestModel()) + ]) + ) + var keychainValue: TestModel? +} diff --git a/Libraries/Tests/KeychainStorageTests/Helpers/Extensions/String+KeychainStorageKeys.swift b/Libraries/Tests/KeychainStorageTests/Helpers/Extensions/String+KeychainStorageKeys.swift new file mode 100644 index 0000000..26fe8d6 --- /dev/null +++ b/Libraries/Tests/KeychainStorageTests/Helpers/Extensions/String+KeychainStorageKeys.swift @@ -0,0 +1,14 @@ +// +// String+KeychainStorageKeys.swift +// KeychainStorageTests +// +// Created by Javier Cicchelli on 11/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +extension String { + enum Keys { + static let someKey = "com.rockncode.app.assignment.be-real.library.keychain-storage.test.key.some-key" + static let someOtherKey = "com.rockncode.app.assignment.be-real.library.keychain-storage.test.key.some-other-key" + } +} diff --git a/Libraries/Tests/KeychainStorageTests/Helpers/Mocks/KeychainStorageMock.swift b/Libraries/Tests/KeychainStorageTests/Helpers/Mocks/KeychainStorageMock.swift new file mode 100644 index 0000000..e9c6344 --- /dev/null +++ b/Libraries/Tests/KeychainStorageTests/Helpers/Mocks/KeychainStorageMock.swift @@ -0,0 +1,78 @@ +// +// KeychainStorageMock.swift +// KeychainStorageTests +// +// Created by Javier Cicchelli on 11/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import Foundation +import KeychainStorage + +class KeychainStorageMock { + + // MARK: Properties + + private var storage: [String: Data] + private let concurrentQueue = DispatchQueue( + label: "com.rockncode.app.assignment.be-real.library.keychain-storage.test.queue.keychain-storage", + attributes: .concurrent + ) + + // MARK: Initialisers + + init(storage: [String: Data] = [:]) { + self.storage = storage + } + +} + +// MARK: - Keychainable + +extension KeychainStorageMock: Keychainable { + func getData( + _ key: String, + ignoringAttributeSynchronizable: Bool + ) throws -> Data? { + var data: Data? + + try concurrentQueue.sync { + guard let dataValue = storage[key] else { + throw KeychainStorageMockError.keyIsNotDefined + } + + data = dataValue + } + + return data + } + + func set( + _ value: Data, + key: String, + ignoringAttributeSynchronizable: Bool + ) throws { + concurrentQueue.async(flags: .barrier) { + self.storage[key] = value + } + } + + func remove( + _ key: String, + ignoringAttributeSynchronizable: Bool + ) throws { + guard storage.keys.contains(key) else { + throw KeychainStorageMockError.keyIsNotDefined + } + + concurrentQueue.async(flags: .barrier) { + self.storage.removeValue(forKey: key) + } + } +} + +// MARK: - KeychainStorageSpyError + +enum KeychainStorageMockError: Error { + case keyIsNotDefined +} diff --git a/Libraries/Tests/KeychainStorageTests/Helpers/Models/TestModel.swift b/Libraries/Tests/KeychainStorageTests/Helpers/Models/TestModel.swift new file mode 100644 index 0000000..08c698e --- /dev/null +++ b/Libraries/Tests/KeychainStorageTests/Helpers/Models/TestModel.swift @@ -0,0 +1,17 @@ +// +// TestModel.swift +// KeychainStorageTests +// +// Created by Javier Cicchelli on 11/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import Foundation + +struct TestModel: Codable, Equatable { + var uuid: String = UUID().uuidString + var text: String = "Some text goes in here..." + var number: Int = .random(in: 1 ... 100) + var boolean: Bool = .random() + var date: Date = .now +}