From 365d92f21625eba01ea83b4fa64cb5436a227d37 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 11 Dec 2022 18:54:23 +0100 Subject: [PATCH 1/4] Defined the "KeychainStorage" target and test target in the Libraries package. --- .../xcshareddata/xcschemes/Libraries.xcscheme | 92 +++++++++++++++++++ Libraries/Package.swift | 17 +++- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 Libraries/.swiftpm/xcode/xcshareddata/xcschemes/Libraries.xcscheme diff --git a/Libraries/.swiftpm/xcode/xcshareddata/xcschemes/Libraries.xcscheme b/Libraries/.swiftpm/xcode/xcshareddata/xcschemes/Libraries.xcscheme new file mode 100644 index 0000000..d847438 --- /dev/null +++ b/Libraries/.swiftpm/xcode/xcshareddata/xcschemes/Libraries.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Libraries/Package.swift b/Libraries/Package.swift index 8e572f0..9620c9d 100644 --- a/Libraries/Package.swift +++ b/Libraries/Package.swift @@ -14,13 +14,28 @@ let package = Package( ] ), ], - dependencies: [], + dependencies: [ + .package( + url: "https://github.com/kishikawakatsumi/KeychainAccess.git", + from: "4.0.0" + ), + ], targets: [ .target(name: "APIService"), .target(name: "DependencyService"), + .target( + name: "KeychainStorage", + dependencies: [ + "KeychainAccess" + ] + ), .testTarget( name: "APIServiceTests", dependencies: ["APIService"] ), + .testTarget( + name: "KeychainStorageTests", + dependencies: ["KeychainStorage"] + ) ] ) From 75204148b0188003c440125d09cfad75ade6f494 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 11 Dec 2022 18:55:02 +0100 Subject: [PATCH 2/4] Defined the Keychainable protocol. --- .../Protocols/Keychainable.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 Libraries/Sources/KeychainStorage/Protocols/Keychainable.swift diff --git a/Libraries/Sources/KeychainStorage/Protocols/Keychainable.swift b/Libraries/Sources/KeychainStorage/Protocols/Keychainable.swift new file mode 100644 index 0000000..7b0b55c --- /dev/null +++ b/Libraries/Sources/KeychainStorage/Protocols/Keychainable.swift @@ -0,0 +1,27 @@ +// +// Keychainable.swift +// KeychainStorage +// +// Created by Javier Cicchelli on 11/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import Foundation + +public protocol Keychainable { + func getData( + _ key: String, + ignoringAttributeSynchronizable: Bool + ) throws -> Data? + + func set( + _ value: Data, + key: String, + ignoringAttributeSynchronizable: Bool + ) throws + + func remove( + _ key: String, + ignoringAttributeSynchronizable: Bool + ) throws +} From a3bfced62ac8f725d0fbd9cdd710ad390207f75c Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 11 Dec 2022 18:55:30 +0100 Subject: [PATCH 3/4] Conformed the Keychain class from the KeychainAccess dependency to the Keychainable protocol. --- .../Conformances/Keychain+Keychainable.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 Libraries/Sources/KeychainStorage/Conformances/Keychain+Keychainable.swift diff --git a/Libraries/Sources/KeychainStorage/Conformances/Keychain+Keychainable.swift b/Libraries/Sources/KeychainStorage/Conformances/Keychain+Keychainable.swift new file mode 100644 index 0000000..e4bf596 --- /dev/null +++ b/Libraries/Sources/KeychainStorage/Conformances/Keychain+Keychainable.swift @@ -0,0 +1,11 @@ +// +// Keychain+Keychainable.swift +// KeychainStorage +// +// Created by Javier Cicchelli on 11/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import KeychainAccess + +extension Keychain: Keychainable {} From 4a8fff4c167a6b7f347cba0d1da047cf4611a7d0 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 11 Dec 2022 18:57:53 +0100 Subject: [PATCH 4/4] Implemented the KeychainStorage dynamic property wrapper. --- .../Property Wrappers/KeychainStorage.swift | 84 ++++++++++++++ .../KeychainStorage+InitTests.swift | 105 ++++++++++++++++++ .../String+KeychainStorageKeys.swift | 14 +++ .../Helpers/Mocks/KeychainStorageMock.swift | 78 +++++++++++++ .../Helpers/Models/TestModel.swift | 17 +++ 5 files changed, 298 insertions(+) create mode 100644 Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift create mode 100644 Libraries/Tests/KeychainStorageTests/Cases/Property Wrappers/KeychainStorage+InitTests.swift create mode 100644 Libraries/Tests/KeychainStorageTests/Helpers/Extensions/String+KeychainStorageKeys.swift create mode 100644 Libraries/Tests/KeychainStorageTests/Helpers/Mocks/KeychainStorageMock.swift create mode 100644 Libraries/Tests/KeychainStorageTests/Helpers/Models/TestModel.swift 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 +}