Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e1faaa4e62
commit
e0d03c5a86
23
Package.resolved
Normal file
23
Package.resolved
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-syntax",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0687f71944021d616d34d922343dcef086855920",
|
||||||
|
"version" : "600.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-testing",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-testing.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||||
|
"version" : "0.99.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
@ -15,13 +15,19 @@ let package = Package(
|
|||||||
targets: ["LocalData"]
|
targets: ["LocalData"]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/apple/swift-testing.git", from: "0.7.0")
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "LocalData"
|
name: "LocalData"
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "LocalDataTests",
|
name: "LocalDataTests",
|
||||||
dependencies: ["LocalData"]
|
dependencies: [
|
||||||
|
"LocalData",
|
||||||
|
.product(name: "Testing", package: "swift-testing")
|
||||||
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
13
README.md
13
README.md
@ -24,14 +24,14 @@ StorageRouter (main entry point)
|
|||||||
### Services (Actors)
|
### Services (Actors)
|
||||||
- **StorageRouter** - Main entry point for all storage operations
|
- **StorageRouter** - Main entry point for all storage operations
|
||||||
- **KeychainHelper** - Secure keychain storage
|
- **KeychainHelper** - Secure keychain storage
|
||||||
- **EncryptionHelper** - AES-256-GCM encryption with PBKDF2
|
- **EncryptionHelper** - AES-256-GCM or ChaCha20-Poly1305 with PBKDF2/HKDF
|
||||||
- **FileStorageHelper** - File system operations
|
- **FileStorageHelper** - File system operations
|
||||||
- **UserDefaultsHelper** - UserDefaults with suite support
|
- **UserDefaultsHelper** - UserDefaults with suite support
|
||||||
- **SyncHelper** - WatchConnectivity sync
|
- **SyncHelper** - WatchConnectivity sync
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
- **StorageDomain** - userDefaults, keychain, fileSystem, encryptedFileSystem
|
- **StorageDomain** - userDefaults, keychain, fileSystem, encryptedFileSystem
|
||||||
- **SecurityPolicy** - none, keychain, encrypted (AES-256)
|
- **SecurityPolicy** - none, keychain, encrypted (AES-256 or ChaCha20-Poly1305)
|
||||||
- **Serializer** - JSON, plist, Data, or custom
|
- **Serializer** - JSON, plist, Data, or custom
|
||||||
- **PlatformAvailability** - all, phoneOnly, watchOnly, phoneWithWatchSync
|
- **PlatformAvailability** - all, phoneOnly, watchOnly, phoneWithWatchSync
|
||||||
- **SyncPolicy** - never, manual, automaticSmall
|
- **SyncPolicy** - never, manual, automaticSmall
|
||||||
@ -66,6 +66,7 @@ extension StorageKeys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
If you omit `security`, it defaults to `SecurityPolicy.recommended`.
|
||||||
|
|
||||||
### 2. Use StorageRouter
|
### 2. Use StorageRouter
|
||||||
```swift
|
```swift
|
||||||
@ -87,7 +88,7 @@ try await StorageRouter.shared.remove(key)
|
|||||||
| `userDefaults` | Preferences, small settings |
|
| `userDefaults` | Preferences, small settings |
|
||||||
| `keychain` | Credentials, tokens, sensitive data |
|
| `keychain` | Credentials, tokens, sensitive data |
|
||||||
| `fileSystem` | Documents, cached data, large files |
|
| `fileSystem` | Documents, cached data, large files |
|
||||||
| `encryptedFileSystem` | Sensitive files with AES-256 encryption |
|
| `encryptedFileSystem` | Sensitive files with encryption policies |
|
||||||
|
|
||||||
## Security Options
|
## Security Options
|
||||||
|
|
||||||
@ -109,9 +110,11 @@ try await StorageRouter.shared.remove(key)
|
|||||||
- `biometryCurrentSetOrDevicePasscode` - Current biometric or passcode
|
- `biometryCurrentSetOrDevicePasscode` - Current biometric or passcode
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
- AES-256-GCM with PBKDF2-SHA256 key derivation
|
- AES-256-GCM or ChaCha20-Poly1305
|
||||||
- Configurable iteration count
|
- PBKDF2-SHA256 or HKDF-SHA256 key derivation
|
||||||
|
- Configurable PBKDF2 iteration count
|
||||||
- Master key stored securely in keychain
|
- Master key stored securely in keychain
|
||||||
|
- Default security policy: `SecurityPolicy.recommended` (ChaCha20-Poly1305 + HKDF)
|
||||||
|
|
||||||
## Sync Behavior
|
## Sync Behavior
|
||||||
|
|
||||||
|
|||||||
@ -7,11 +7,17 @@ public enum SecurityPolicy: Sendable {
|
|||||||
case encrypted(EncryptionPolicy)
|
case encrypted(EncryptionPolicy)
|
||||||
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
|
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
|
||||||
|
|
||||||
|
public static let recommended: SecurityPolicy = .encrypted(.recommended)
|
||||||
|
|
||||||
public enum EncryptionPolicy: Sendable {
|
public enum EncryptionPolicy: Sendable {
|
||||||
case aes256(keyDerivation: KeyDerivation)
|
case aes256(keyDerivation: KeyDerivation)
|
||||||
|
case chacha20Poly1305(keyDerivation: KeyDerivation)
|
||||||
|
|
||||||
|
public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf())
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum KeyDerivation: Sendable {
|
public enum KeyDerivation: Sendable {
|
||||||
case pbkdf2(iterations: Int, salt: Data? = nil)
|
case pbkdf2(iterations: Int, salt: Data? = nil)
|
||||||
|
case hkdf(salt: Data? = nil, info: Data? = nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
Sources/LocalData/Protocols/StorageKey+Defaults.swift
Normal file
3
Sources/LocalData/Protocols/StorageKey+Defaults.swift
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
public extension StorageKey {
|
||||||
|
var security: SecurityPolicy { .recommended }
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ public actor EncryptionHelper {
|
|||||||
static let masterKeyService = "LocalData.MasterKey"
|
static let masterKeyService = "LocalData.MasterKey"
|
||||||
static let masterKeyAccount = "LocalData.MasterKey"
|
static let masterKeyAccount = "LocalData.MasterKey"
|
||||||
static let masterKeyLength = 32
|
static let masterKeyLength = 32
|
||||||
|
static let defaultHKDFInfo = "LocalData.Encryption"
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
@ -30,7 +31,7 @@ public actor EncryptionHelper {
|
|||||||
policy: SecurityPolicy.EncryptionPolicy
|
policy: SecurityPolicy.EncryptionPolicy
|
||||||
) async throws -> Data {
|
) async throws -> Data {
|
||||||
let key = try await deriveKey(keyName: keyName, policy: policy)
|
let key = try await deriveKey(keyName: keyName, policy: policy)
|
||||||
return try encryptWithKey(data, using: key)
|
return try encryptWithKey(data, using: key, policy: policy)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypts data using AES-GCM.
|
/// Decrypts data using AES-GCM.
|
||||||
@ -46,7 +47,7 @@ public actor EncryptionHelper {
|
|||||||
policy: SecurityPolicy.EncryptionPolicy
|
policy: SecurityPolicy.EncryptionPolicy
|
||||||
) async throws -> Data {
|
) async throws -> Data {
|
||||||
let key = try await deriveKey(keyName: keyName, policy: policy)
|
let key = try await deriveKey(keyName: keyName, policy: policy)
|
||||||
return try decryptWithKey(data, using: key)
|
return try decryptWithKey(data, using: key, policy: policy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Key Derivation
|
// MARK: - Key Derivation
|
||||||
@ -56,30 +57,47 @@ public actor EncryptionHelper {
|
|||||||
keyName: String,
|
keyName: String,
|
||||||
policy: SecurityPolicy.EncryptionPolicy
|
policy: SecurityPolicy.EncryptionPolicy
|
||||||
) async throws -> SymmetricKey {
|
) async throws -> SymmetricKey {
|
||||||
|
let keyDerivation: SecurityPolicy.KeyDerivation
|
||||||
switch policy {
|
switch policy {
|
||||||
case .aes256(let keyDerivation):
|
case .aes256(let derivation),
|
||||||
|
.chacha20Poly1305(let derivation):
|
||||||
|
keyDerivation = derivation
|
||||||
|
}
|
||||||
|
|
||||||
let masterKey = try await getMasterKey()
|
let masterKey = try await getMasterKey()
|
||||||
|
return try deriveKeyMaterial(
|
||||||
|
keyName: keyName,
|
||||||
|
derivation: keyDerivation,
|
||||||
|
masterKey: masterKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Extract salt and iterations from key derivation
|
/// Derives key material based on the provided key derivation strategy.
|
||||||
let (salt, iterations) = extractDerivationParams(keyDerivation, keyName: keyName)
|
private func deriveKeyMaterial(
|
||||||
|
keyName: String,
|
||||||
|
derivation: SecurityPolicy.KeyDerivation,
|
||||||
|
masterKey: Data
|
||||||
|
) throws -> SymmetricKey {
|
||||||
|
switch derivation {
|
||||||
|
case .pbkdf2(let iterations, let customSalt):
|
||||||
|
let salt = customSalt ?? defaultSalt(for: keyName)
|
||||||
let derivedKeyData = try pbkdf2SHA256(
|
let derivedKeyData = try pbkdf2SHA256(
|
||||||
password: masterKey,
|
password: masterKey,
|
||||||
salt: salt,
|
salt: salt,
|
||||||
iterations: iterations,
|
iterations: iterations,
|
||||||
keyLength: Constants.masterKeyLength
|
keyLength: Constants.masterKeyLength
|
||||||
)
|
)
|
||||||
|
|
||||||
return SymmetricKey(data: derivedKeyData)
|
return SymmetricKey(data: derivedKeyData)
|
||||||
}
|
case .hkdf(let customSalt, let customInfo):
|
||||||
}
|
let salt = customSalt ?? defaultSalt(for: keyName)
|
||||||
|
let info = customInfo ?? Data(Constants.defaultHKDFInfo.utf8)
|
||||||
/// Extracts parameters from KeyDerivation enum.
|
let inputKey = SymmetricKey(data: masterKey)
|
||||||
private func extractDerivationParams(_ derivation: SecurityPolicy.KeyDerivation, keyName: String) -> (salt: Data, iterations: Int) {
|
return HKDF<SHA256>.deriveKey(
|
||||||
switch derivation {
|
inputKeyMaterial: inputKey,
|
||||||
case .pbkdf2(let iterations, let customSalt):
|
salt: salt,
|
||||||
let salt = customSalt ?? Data(keyName.utf8)
|
info: info,
|
||||||
return (salt, iterations)
|
outputByteCount: Constants.masterKeyLength
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,7 +133,20 @@ public actor EncryptionHelper {
|
|||||||
|
|
||||||
// MARK: - AES-GCM Operations
|
// MARK: - AES-GCM Operations
|
||||||
|
|
||||||
private func encryptWithKey(_ data: Data, using key: SymmetricKey) throws -> Data {
|
private func encryptWithKey(
|
||||||
|
_ data: Data,
|
||||||
|
using key: SymmetricKey,
|
||||||
|
policy: SecurityPolicy.EncryptionPolicy
|
||||||
|
) throws -> Data {
|
||||||
|
switch policy {
|
||||||
|
case .aes256:
|
||||||
|
return try encryptWithAESGCM(data, using: key)
|
||||||
|
case .chacha20Poly1305:
|
||||||
|
return try encryptWithChaChaPoly(data, using: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data {
|
||||||
do {
|
do {
|
||||||
let sealedBox = try AES.GCM.seal(data, using: key)
|
let sealedBox = try AES.GCM.seal(data, using: key)
|
||||||
guard let combined = sealedBox.combined else {
|
guard let combined = sealedBox.combined else {
|
||||||
@ -127,7 +158,20 @@ public actor EncryptionHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decryptWithKey(_ data: Data, using key: SymmetricKey) throws -> Data {
|
private func decryptWithKey(
|
||||||
|
_ data: Data,
|
||||||
|
using key: SymmetricKey,
|
||||||
|
policy: SecurityPolicy.EncryptionPolicy
|
||||||
|
) throws -> Data {
|
||||||
|
switch policy {
|
||||||
|
case .aes256:
|
||||||
|
return try decryptWithAESGCM(data, using: key)
|
||||||
|
case .chacha20Poly1305:
|
||||||
|
return try decryptWithChaChaPoly(data, using: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data {
|
||||||
do {
|
do {
|
||||||
let sealedBox = try AES.GCM.SealedBox(combined: data)
|
let sealedBox = try AES.GCM.SealedBox(combined: data)
|
||||||
return try AES.GCM.open(sealedBox, using: key)
|
return try AES.GCM.open(sealedBox, using: key)
|
||||||
@ -136,6 +180,24 @@ public actor EncryptionHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func encryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data {
|
||||||
|
do {
|
||||||
|
let sealedBox = try ChaChaPoly.seal(data, using: key)
|
||||||
|
return sealedBox.combined
|
||||||
|
} catch {
|
||||||
|
throw StorageError.securityApplicationFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data {
|
||||||
|
do {
|
||||||
|
let sealedBox = try ChaChaPoly.SealedBox(combined: data)
|
||||||
|
return try ChaChaPoly.open(sealedBox, using: key)
|
||||||
|
} catch {
|
||||||
|
throw StorageError.securityApplicationFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - PBKDF2 Implementation
|
// MARK: - PBKDF2 Implementation
|
||||||
|
|
||||||
private func pbkdf2SHA256(
|
private func pbkdf2SHA256(
|
||||||
@ -188,4 +250,8 @@ public actor EncryptionHelper {
|
|||||||
var bigEndian = value.bigEndian
|
var bigEndian = value.bigEndian
|
||||||
return Data(bytes: &bigEndian, count: MemoryLayout<UInt32>.size)
|
return Data(bytes: &bigEndian, count: MemoryLayout<UInt32>.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func defaultSalt(for keyName: String) -> Data {
|
||||||
|
Data(keyName.utf8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
Tests/LocalDataTests/EncryptionHelperTests.swift
Normal file
57
Tests/LocalDataTests/EncryptionHelperTests.swift
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import LocalData
|
||||||
|
|
||||||
|
struct EncryptionHelperTests {
|
||||||
|
private let masterKeyService = "LocalData.MasterKey"
|
||||||
|
private let keyName = "LocalDataTests.encryption"
|
||||||
|
private let payload = Data("payload".utf8)
|
||||||
|
|
||||||
|
@Test func aesGCMWithPBKDF2RoundTrip() async throws {
|
||||||
|
await clearMasterKey()
|
||||||
|
|
||||||
|
let policy: SecurityPolicy.EncryptionPolicy = .aes256(
|
||||||
|
keyDerivation: .pbkdf2(iterations: 1_000)
|
||||||
|
)
|
||||||
|
|
||||||
|
let encrypted = try await EncryptionHelper.shared.encrypt(
|
||||||
|
payload,
|
||||||
|
keyName: keyName,
|
||||||
|
policy: policy
|
||||||
|
)
|
||||||
|
let decrypted = try await EncryptionHelper.shared.decrypt(
|
||||||
|
encrypted,
|
||||||
|
keyName: keyName,
|
||||||
|
policy: policy
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(decrypted == payload)
|
||||||
|
await clearMasterKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func chaChaPolyWithHKDFRoundTrip() async throws {
|
||||||
|
await clearMasterKey()
|
||||||
|
|
||||||
|
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
|
||||||
|
keyDerivation: .hkdf()
|
||||||
|
)
|
||||||
|
|
||||||
|
let encrypted = try await EncryptionHelper.shared.encrypt(
|
||||||
|
payload,
|
||||||
|
keyName: keyName,
|
||||||
|
policy: policy
|
||||||
|
)
|
||||||
|
let decrypted = try await EncryptionHelper.shared.decrypt(
|
||||||
|
encrypted,
|
||||||
|
keyName: keyName,
|
||||||
|
policy: policy
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(decrypted == payload)
|
||||||
|
await clearMasterKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearMasterKey() async {
|
||||||
|
try? await KeychainHelper.shared.deleteAll(service: masterKeyService)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import LocalData
|
@testable import LocalData
|
||||||
|
|
||||||
private struct TestUserDefaultsKey: StorageKey {
|
private struct TestUserDefaultsKey: StorageKey {
|
||||||
@ -36,66 +36,48 @@ private struct TestFileKey: StorageKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class LocalDataTests: XCTestCase {
|
struct LocalDataTests {
|
||||||
private var suiteName: String = ""
|
@Test func userDefaultsRoundTrip() async throws {
|
||||||
private var tempDirectory: URL = .temporaryDirectory
|
let suiteName = "LocalDataTests.\(UUID().uuidString)"
|
||||||
|
defer {
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
suiteName = "LocalDataTests.\(UUID().uuidString)"
|
|
||||||
tempDirectory = FileManager.default.temporaryDirectory
|
|
||||||
.appending(path: "LocalDataTests")
|
|
||||||
.appending(path: UUID().uuidString)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
if let defaults = UserDefaults(suiteName: suiteName) {
|
if let defaults = UserDefaults(suiteName: suiteName) {
|
||||||
defaults.removePersistentDomain(forName: suiteName)
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
}
|
}
|
||||||
|
|
||||||
try? FileManager.default.removeItem(at: tempDirectory)
|
|
||||||
super.tearDown()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUserDefaultsRoundTrip() async throws {
|
|
||||||
let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName)
|
let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName)
|
||||||
let storedValue = "1.0.0"
|
let storedValue = "1.0.0"
|
||||||
|
|
||||||
try await StorageRouter.shared.set(storedValue, for: key)
|
try await StorageRouter.shared.set(storedValue, for: key)
|
||||||
let fetched = try await StorageRouter.shared.get(key)
|
let fetched = try await StorageRouter.shared.get(key)
|
||||||
|
|
||||||
XCTAssertEqual(fetched, storedValue)
|
#expect(fetched == storedValue)
|
||||||
|
|
||||||
try await StorageRouter.shared.remove(key)
|
try await StorageRouter.shared.remove(key)
|
||||||
|
await #expect(throws: StorageError.notFound) {
|
||||||
do {
|
|
||||||
_ = try await StorageRouter.shared.get(key)
|
_ = try await StorageRouter.shared.get(key)
|
||||||
XCTFail("Expected notFound error after removal")
|
|
||||||
} catch StorageError.notFound {
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
XCTFail("Unexpected error: \(error)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFileSystemRoundTrip() async throws {
|
@Test func fileSystemRoundTrip() async throws {
|
||||||
|
let tempDirectory = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "LocalDataTests")
|
||||||
|
.appending(path: UUID().uuidString)
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: tempDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
let key = TestFileKey(name: "test.json", directory: tempDirectory)
|
let key = TestFileKey(name: "test.json", directory: tempDirectory)
|
||||||
let storedValue = "payload"
|
let storedValue = "payload"
|
||||||
|
|
||||||
try await StorageRouter.shared.set(storedValue, for: key)
|
try await StorageRouter.shared.set(storedValue, for: key)
|
||||||
let fetched = try await StorageRouter.shared.get(key)
|
let fetched = try await StorageRouter.shared.get(key)
|
||||||
|
|
||||||
XCTAssertEqual(fetched, storedValue)
|
#expect(fetched == storedValue)
|
||||||
|
|
||||||
try await StorageRouter.shared.remove(key)
|
try await StorageRouter.shared.remove(key)
|
||||||
|
await #expect(throws: StorageError.notFound) {
|
||||||
do {
|
|
||||||
_ = try await StorageRouter.shared.get(key)
|
_ = try await StorageRouter.shared.get(key)
|
||||||
XCTFail("Expected notFound error after removal")
|
|
||||||
} catch StorageError.notFound {
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
XCTFail("Unexpected error: \(error)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user