From a45a68490468cbdb03e0e3be9494e2fd75a73fb0 Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 15 Dec 2023 10:44:42 -0700 Subject: [PATCH] Implement SecureState and Tests --- .../Application/Application+public.swift | 72 ++++++++++++++ .../Types/Application+SecureState.swift | 55 +++++++++++ .../PropertyWrappers/SecureState.swift | 95 +++++++++++++++++++ Tests/AppStateTests/SecureStateTests.swift | 66 +++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 Sources/AppState/Application/Types/Application+SecureState.swift create mode 100644 Sources/AppState/PropertyWrappers/SecureState.swift create mode 100644 Tests/AppStateTests/SecureStateTests.swift diff --git a/Sources/AppState/Application/Application+public.swift b/Sources/AppState/Application/Application+public.swift index 443b4c4..f1e3d55 100644 --- a/Sources/AppState/Application/Application+public.swift +++ b/Sources/AppState/Application/Application+public.swift @@ -549,3 +549,75 @@ public extension Application { ) } } + +// MARK: SecureState Functions + +public extension Application { + static func reset( + secureState keyPath: KeyPath, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + log( + debug: "🔑 Resetting SecureState \(String(describing: keyPath))", + fileID: fileID, + function: function, + line: line, + column: column + ) + + var secureState = shared.value(keyPath: keyPath) + secureState.reset() + } + + static func secureState( + _ keyPath: KeyPath, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> SecureState { + let secureState = shared.value(keyPath: keyPath) + let debugMessage: String + + #if DEBUG + debugMessage = "🔑 Getting SecureState \(String(describing: keyPath)) -> \(String(describing: secureState.value))" + #else + debugMessage = "🔑 Getting SecureState \(String(describing: keyPath))" + #endif + + log( + debug: debugMessage, + fileID: fileID, + function: function, + line: line, + column: column + ) + + return secureState + } + + func secureState( + initial: @escaping @autoclosure () -> String?, + feature: String = "App", + id: String + ) -> SecureState { + SecureState( + initial: initial(), + scope: Scope(name: feature, id: id) + ) + } + + func secureState( + feature: String = "App", + id: String + ) -> SecureState { + secureState( + initial: nil, + feature: feature, + id: id + ) + } +} diff --git a/Sources/AppState/Application/Types/Application+SecureState.swift b/Sources/AppState/Application/Types/Application+SecureState.swift new file mode 100644 index 0000000..42e418f --- /dev/null +++ b/Sources/AppState/Application/Types/Application+SecureState.swift @@ -0,0 +1,55 @@ +import Security +import Foundation + +extension Application { + public var keychain: Dependency { + dependency(Keychain()) + } + + public struct SecureState { + @AppDependency(\.keychain) private var keychain: Keychain + + /// The initial value of the state. + private var initial: () -> String? + + /// The current state value. + public var value: String? { + get { + guard + let storedValue = keychain.get(scope.key, as: String.self) + else { return initial() } + + return storedValue + } + set { + guard let newValue else { + return keychain.remove(scope.key) + } + + keychain.set(value: newValue, forKey: scope.key) + } + } + + /// The scope in which this state exists. + let scope: Scope + + /** + Creates a new state within a given scope initialized with the provided value. + + - Parameters: + - value: The initial value of the state + - scope: The scope in which the state exists + */ + init( + initial: @escaping @autoclosure () -> String?, + scope: Scope + ) { + self.initial = initial + self.scope = scope + } + + public mutating func reset() { + value = initial() + } + } +} diff --git a/Sources/AppState/PropertyWrappers/SecureState.swift b/Sources/AppState/PropertyWrappers/SecureState.swift new file mode 100644 index 0000000..6ffc331 --- /dev/null +++ b/Sources/AppState/PropertyWrappers/SecureState.swift @@ -0,0 +1,95 @@ +import Foundation +import Combine +import SwiftUI + +@propertyWrapper public struct SecureState: DynamicProperty { + /// Holds the singleton instance of `Application`. + @ObservedObject private var app: Application = Application.shared + + /// Path for accessing `SecureState` from Application. + private let keyPath: KeyPath + + private let fileID: StaticString + private let function: StaticString + private let line: Int + private let column: Int + + /// Represents the current value of the `SecureState`. + public var wrappedValue: String? { + get { + Application.secureState( + keyPath, + fileID, + function, + line, + column + ).value + } + nonmutating set { + let debugMessage: String + + #if DEBUG + debugMessage = "🔑 Setting SecureState \(String(describing: keyPath)) = \(String(describing: newValue))" + #else + debugMessage = "🔑 Setting SecureState \(String(describing: keyPath))" + #endif + + Application.log( + debug: debugMessage, + fileID: fileID, + function: function, + line: line, + column: column + ) + + var state = app.value(keyPath: keyPath) + state.value = newValue + } + } + + /// A binding to the `State`'s value, which can be used with SwiftUI views. + public var projectedValue: Binding { + Binding( + get: { wrappedValue }, + set: { wrappedValue = $0 } + ) + } + + /** + Initializes the AppState with a `keyPath` for accessing `SecureState` in Application. + + - Parameter keyPath: The `KeyPath` for accessing `SecureState` in Application. + */ + public init( + _ keyPath: KeyPath, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + self.keyPath = keyPath + self.fileID = fileID + self.function = function + self.line = line + self.column = column + } + + /// A property wrapper's synthetic storage property. This is just for SwiftUI to mutate the `wrappedValue` and send event through `objectWillChange` publisher when the `wrappedValue` changes + public static subscript( + _enclosingInstance observed: OuterSelf, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> String? { + get { + observed[keyPath: storageKeyPath].wrappedValue + } + set { + guard + let publisher = observed.objectWillChange as? ObservableObjectPublisher + else { return } + + publisher.send() + observed[keyPath: storageKeyPath].wrappedValue = newValue + } + } +} diff --git a/Tests/AppStateTests/SecureStateTests.swift b/Tests/AppStateTests/SecureStateTests.swift new file mode 100644 index 0000000..1991105 --- /dev/null +++ b/Tests/AppStateTests/SecureStateTests.swift @@ -0,0 +1,66 @@ +import SwiftUI +import XCTest +@testable import AppState + +fileprivate extension Application { + var secureValue: SecureState { + secureState(id: "secureState") + } +} + +fileprivate struct ExampleStoredValue { + @SecureState(\.secureValue) var token: String? +} + +fileprivate class ExampleStoringViewModel: ObservableObject { + @SecureState(\.secureValue) var token: String? + + func testPropertyWrapper() { + token = "QWERTY" + _ = Picker("Picker", selection: $token, content: EmptyView.init) + } +} + +final class SecureStateTests: XCTestCase { + override class func setUp() { + Application.logging(isEnabled: true) + } + + override class func tearDown() { + Application.logger.debug("StoredStateTests \(Application.description)") + } + + func testStoredState() { + XCTAssertNil(Application.secureState(\.secureValue).value) + + let secureValue = ExampleStoredValue() + + XCTAssertEqual(secureValue.token, nil) + + secureValue.token = "QWERTY" + + XCTAssertEqual(secureValue.token, "QWERTY") + + Application.logger.debug("StoredStateTests \(Application.description)") + + secureValue.token = nil + + XCTAssertNil(Application.secureState(\.secureValue).value) + } + + func testStoringViewModel() { + XCTAssertNil(Application.secureState(\.secureValue).value) + + let viewModel = ExampleStoringViewModel() + + XCTAssertEqual(viewModel.token, nil) + + viewModel.testPropertyWrapper() + + XCTAssertEqual(viewModel.token, "QWERTY") + + Application.reset(secureState: \.secureValue) + + XCTAssertNil(viewModel.token) + } +}