Merge pull request #7 from rock-n-code/feature/keychain-storage

Feature: Keychain store
This commit is contained in:
Javier Cicchelli 2022-12-11 19:02:29 +01:00 committed by GitHub
commit 97ad38bbc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 444 additions and 1 deletions

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1410"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Libraries"
BuildableName = "Libraries"
BlueprintName = "Libraries"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "APIServiceTests"
BuildableName = "APIServiceTests"
BlueprintName = "APIServiceTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "KeychainStorageTests"
BuildableName = "KeychainStorageTests"
BlueprintName = "KeychainStorageTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Libraries"
BuildableName = "Libraries"
BlueprintName = "Libraries"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -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"]
)
]
)

View File

@ -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 {}

View File

@ -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<Model: Codable>: 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<Value?> {
.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)
}
}
}

View File

@ -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
}

View File

@ -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?
}

View File

@ -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"
}
}

View File

@ -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
}

View File

@ -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
}