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