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 }