import Foundation import HostRegistry import Security import Testing import WireProtocol /// T-iOS-15 · Keychain-on-simulator assertion (deferred from T-iOS-7). /// /// `swift test` binaries are unsigned → the data-protection keychain answers /// `errSecMissingEntitlement` (-34018), so the package layer only tests the /// store against a fake `SecItemShim`. THIS bundle runs signed inside the /// WebTerm app host, so here the REAL `LiveSecItemShim` path is exercised /// end-to-end and the §5.3 protection class is asserted on the stored item. @Suite("KeychainHostStore (real keychain, signed app host)", .serialized) struct KeychainHostStoreLiveTests { /// Test-only service/account — never collides with the production item. private static let service = "com.yaojia.webterm.host-registry.livetests" private static let account = "hosts-live-roundtrip" private static func makeHost() throws -> HostRegistry.Host { let url = try #require(URL(string: "http://192.168.1.9:3000")) let endpoint = try #require(HostEndpoint(baseURL: url)) return HostRegistry.Host(id: UUID(), name: "live-roundtrip", endpoint: endpoint) } /// Removes the test item regardless of test outcome. private static func deleteTestItem() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecUseDataProtectionKeychain as String: true, ] SecItemDelete(query as CFDictionary) } @Test("真 Keychain 往返(upsert/loadAll/remove)+ kSecAttrAccessible 属性断言") func realKeychainRoundtripAndProtectionClass() async throws { Self.deleteTestItem() // clean slate from any earlier aborted run defer { Self.deleteTestItem() } // never leave the test item behind // Arrange: REAL shim (default init), isolated service/account. let store = KeychainHostStore(service: Self.service, account: Self.account) let host = try Self.makeHost() // Act + Assert: upsert → visible via loadAll. let afterUpsert = try await store.upsert(host) #expect(afterUpsert == [host]) #expect(try await store.loadAll() == [host]) // Assert §5.3: the STORED item's protection class, straight from // SecItemCopyMatching attributes (not from our own spec constants). var result: CFTypeRef? let attributesQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: Self.service, kSecAttrAccount as String: Self.account, kSecUseDataProtectionKeychain as String: true, kSecReturnAttributes as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] let status = SecItemCopyMatching(attributesQuery as CFDictionary, &result) #expect(status == errSecSuccess, "SecItemCopyMatching failed: \(status)") let attributes = try #require(result as? [String: Any]) let accessible = try #require(attributes[kSecAttrAccessible as String] as? String) #expect( accessible == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String, "stored item must be AfterFirstUnlockThisDeviceOnly (§5.3), got \(accessible)" ) // §5.3: never iCloud-synced — the item must not be synchronizable. let synchronizable = attributes[kSecAttrSynchronizable as String] as? Bool ?? false #expect(!synchronizable) // Act + Assert: remove → empty store (item deleted). let afterRemove = try await store.remove(id: host.id) #expect(afterRemove.isEmpty) #expect(try await store.loadAll() == []) } }