Skip to content

Commit

Permalink
Implement SecureState and Tests
Browse files Browse the repository at this point in the history
  • Loading branch information
0xLeif committed Dec 15, 2023
1 parent 183b681 commit a45a684
Show file tree
Hide file tree
Showing 4 changed files with 288 additions and 0 deletions.
72 changes: 72 additions & 0 deletions Sources/AppState/Application/Application+public.swift
Original file line number Diff line number Diff line change
Expand Up @@ -549,3 +549,75 @@ public extension Application {
)
}
}

// MARK: SecureState Functions

public extension Application {
static func reset(
secureState keyPath: KeyPath<Application, SecureState>,
_ 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<Application, SecureState>,
_ 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
)
}
}
55 changes: 55 additions & 0 deletions Sources/AppState/Application/Types/Application+SecureState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Security
import Foundation

extension Application {
public var keychain: Dependency<Keychain> {
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()
}
}
}
95 changes: 95 additions & 0 deletions Sources/AppState/PropertyWrappers/SecureState.swift
Original file line number Diff line number Diff line change
@@ -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<Application, Application.SecureState>

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<String?> {
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<Application, Application.SecureState>,
_ 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<OuterSelf: ObservableObject>(
_enclosingInstance observed: OuterSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, String?>,
storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
) -> String? {
get {
observed[keyPath: storageKeyPath].wrappedValue
}
set {
guard
let publisher = observed.objectWillChange as? ObservableObjectPublisher
else { return }

publisher.send()
observed[keyPath: storageKeyPath].wrappedValue = newValue
}
}
}
66 changes: 66 additions & 0 deletions Tests/AppStateTests/SecureStateTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit a45a684

Please sign in to comment.