apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

RadrootsSecureStoreTests.swift (7154B)


      1 import Foundation
      2 import Security
      3 import Testing
      4 
      5 @testable import RadrootsKit
      6 
      7 @Test func secureStoreKeyBuildsServiceName() throws {
      8     let key = RadrootsSecureStoreKey(namespace: "session", name: "token")
      9     #expect(try key.serviceName(servicePrefix: "org.radroots.test") == "org.radroots.test.session")
     10 }
     11 
     12 @Test func secureStoreKeyNormalizesServicePrefixNamespaceAndName() throws {
     13     let key = RadrootsSecureStoreKey(namespace: " session ", name: " token ")
     14     let normalizedKey = try key.normalized()
     15 
     16     #expect(normalizedKey.namespace == "session")
     17     #expect(normalizedKey.name == "token")
     18     #expect(try key.serviceName(servicePrefix: " org.radroots.test ") == "org.radroots.test.session")
     19 }
     20 
     21 @Test func secureStoreKeyRejectsBlankNamespace() throws {
     22     let key = RadrootsSecureStoreKey(namespace: " ", name: "token")
     23     #expect(throws: RadrootsAppleSecurityError.self) {
     24         _ = try key.serviceName(servicePrefix: "org.radroots.test")
     25     }
     26 }
     27 
     28 @Test func secureStoreKeyRejectsBlankName() throws {
     29     let key = RadrootsSecureStoreKey(namespace: "session", name: " ")
     30     #expect(throws: RadrootsAppleSecurityError.self) {
     31         _ = try key.normalized()
     32     }
     33 }
     34 
     35 @Test func secureStoreKeyRejectsBlankServicePrefix() throws {
     36     let key = RadrootsSecureStoreKey(namespace: "session", name: "token")
     37     #expect(throws: RadrootsAppleSecurityError.self) {
     38         _ = try key.serviceName(servicePrefix: " ")
     39     }
     40 }
     41 
     42 @Test func keychainBaseQueryUsesNormalizedAccountName() throws {
     43     let store = RadrootsAppleKeychainSecureStore(servicePrefix: " org.radroots.tests ")
     44     let query = try store.baseQuery(
     45         for: RadrootsSecureStoreKey(namespace: " identity ", name: " secret ")
     46     )
     47 
     48     #expect(query[kSecAttrService as String] as? String == "org.radroots.tests.identity")
     49     #expect(query[kSecAttrAccount as String] as? String == "secret")
     50 }
     51 
     52 @Test func keychainNamespaceQueryUsesSharedValidation() throws {
     53     let store = RadrootsAppleKeychainSecureStore(servicePrefix: " org.radroots.tests ")
     54     let query = try store.namespaceQuery(" identity ")
     55 
     56     #expect(query[kSecAttrService as String] as? String == "org.radroots.tests.identity")
     57 }
     58 
     59 @Test func keychainStoreRoundTripsLocalSecret() throws {
     60     let store = RadrootsAppleKeychainSecureStore(
     61         servicePrefix: "org.radroots.tests.\(UUID().uuidString)"
     62     )
     63     let key = RadrootsSecureStoreKey(namespace: "roundtrip", name: "token")
     64     let data = Data("secret-token".utf8)
     65 
     66     try store.put(data, for: key)
     67     #expect(try store.get(key) == data)
     68 
     69     try store.delete(key)
     70     #expect(try store.get(key) == nil)
     71 }
     72 
     73 @Test func keychainStoreChecksPresenceWithoutReturningSecret() throws {
     74     let store = RadrootsAppleKeychainSecureStore(
     75         servicePrefix: "org.radroots.tests.\(UUID().uuidString)"
     76     )
     77     let key = RadrootsSecureStoreKey(namespace: "presence", name: "token")
     78 
     79     #expect(try store.contains(key) == false)
     80 
     81     try store.put(Data("secret-token".utf8), for: key)
     82     #expect(try store.contains(key) == true)
     83 
     84     try store.delete(key)
     85     #expect(try store.contains(key) == false)
     86 }
     87 
     88 @Test func keychainStoreReplacesExistingSecret() throws {
     89     let store = RadrootsAppleKeychainSecureStore(
     90         servicePrefix: "org.radroots.tests.\(UUID().uuidString)"
     91     )
     92     let key = RadrootsSecureStoreKey(namespace: "replacement", name: "token")
     93 
     94     try store.put(Data("old-secret".utf8), for: key)
     95     try store.put(Data("new-secret".utf8), for: key)
     96 
     97     #expect(try store.get(key) == Data("new-secret".utf8))
     98     try store.delete(key)
     99 }
    100 
    101 @Test func keychainStorePreservesExistingSecretWhenReplacementPreparationFails() throws {
    102     let servicePrefix = "org.radroots.tests.\(UUID().uuidString)"
    103     let key = RadrootsSecureStoreKey(namespace: "replacement-failure", name: "token")
    104     let store = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix)
    105     let failingStore = RadrootsAppleKeychainSecureStore(
    106         servicePrefix: servicePrefix,
    107         accessControlFactory: { _ in
    108             throw RadrootsAppleSecurityError.invalidRequest
    109         }
    110     )
    111 
    112     try store.put(Data("old-secret".utf8), for: key)
    113     #expect(throws: RadrootsAppleSecurityError.self) {
    114         try failingStore.put(Data("new-secret".utf8), for: key, policy: .userPresenceLocalSecret)
    115     }
    116 
    117     #expect(try store.get(key) == Data("old-secret".utf8))
    118     try store.delete(key)
    119 }
    120 
    121 @Test func secureLocalSecretMapsToDeviceLocalWhenUnlockedKeychainPolicy() {
    122     let store = RadrootsAppleKeychainSecureStore()
    123     let mapping = store.keychainPolicyMapping(for: .secureLocalSecret)
    124 
    125     #expect(
    126         String(mapping.accessibilityConstant) == String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly))
    127     #expect(mapping.usesAccessControl == false)
    128     #expect(mapping.accessControlFlags.isEmpty)
    129 }
    130 
    131 @Test func userPresenceLocalSecretMapsToDeviceLocalWhenUnlockedAccessControl() throws {
    132     let store = RadrootsAppleKeychainSecureStore()
    133     let mapping = store.keychainPolicyMapping(for: .userPresenceLocalSecret)
    134 
    135     #expect(
    136         String(mapping.accessibilityConstant) == String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly))
    137     #expect(mapping.usesAccessControl == true)
    138     #expect(mapping.accessControlFlags == .userPresence)
    139     _ = try store.accessControl(for: mapping)
    140 }
    141 
    142 @Test func afterFirstUnlockPolicyCanRemainDeviceLocal() {
    143     let store = RadrootsAppleKeychainSecureStore()
    144     let mapping = store.keychainPolicyMapping(
    145         for: RadrootsSecretAccessPolicy(
    146             accessibility: .afterFirstUnlock,
    147             deviceLocalOnly: true,
    148             userPresenceRequired: false
    149         )
    150     )
    151 
    152     #expect(
    153         String(mapping.accessibilityConstant)
    154             == String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly))
    155     #expect(mapping.usesAccessControl == false)
    156     #expect(mapping.accessControlFlags.isEmpty)
    157 }
    158 
    159 @Test func resetAllowsMissingState() throws {
    160     let request = RadrootsAppLocalStateResetRequest(
    161         appIdentifier: "org.radroots.tests.\(UUID().uuidString)",
    162         keychainServiceNames: ["org.radroots.tests.\(UUID().uuidString)"]
    163     )
    164 
    165     try RadrootsAppLocalStateReset.reset(request)
    166 }
    167 
    168 @Test func resetClearsNamedKeychainService() throws {
    169     let servicePrefix = "org.radroots.tests.\(UUID().uuidString)"
    170     let store = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix)
    171     let key = RadrootsSecureStoreKey(namespace: "reset", name: "secret")
    172     let serviceName = try key.serviceName(servicePrefix: servicePrefix)
    173 
    174     try store.put(Data("secret".utf8), for: key)
    175     #expect(try store.get(key) == Data("secret".utf8))
    176 
    177     try RadrootsAppLocalStateReset.reset(
    178         RadrootsAppLocalStateResetRequest(
    179             appIdentifier: "org.radroots.tests.\(UUID().uuidString)",
    180             keychainServiceNames: [serviceName]
    181         )
    182     )
    183 
    184     #expect(try store.get(key) == nil)
    185 }
    186 
    187 @Test func userPresenceStatusIsInspectable() async throws {
    188     let userPresence = RadrootsAppleUserPresence()
    189     let status = try await userPresence.currentStatus()
    190     switch status.support {
    191     case .none, .deviceCredential, .biometricsOrDeviceCredential:
    192         break
    193     }
    194 }