import Foundation import Security import Testing import HostRegistry // KeychainHostStore logic against the fake SecItemShim (plan T-iOS-7: // add/update/delete 走对分支、错误码显式映射;真 keychain 归 xcodebuild 模拟器测试)。 // MARK: - Branch selection @Test("upsert 新 host:走 add 分支,不碰 update") func upsertNewHostTakesAddBranchOnly() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() // Act let result = try await store.upsert(host) // Assert #expect(result == [host]) #expect(shim.recordedAdds.count == 1) #expect(shim.recordedUpdates.isEmpty) #expect(try await store.loadAll() == [host]) } @Test("已有条目再 upsert:add 撞 errSecDuplicateItem → 走 update 分支") func upsertOntoExistingItemFallsBackToUpdateBranch() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let first = Fixtures.makeHost(name: "first") _ = try await store.upsert(first) let second = Fixtures.makeHost(name: "second", urlString: "http://10.0.0.9:3000") // Act let result = try await store.upsert(second) // Assert #expect(result == [first, second]) #expect(shim.recordedUpdates.count == 1) #expect(try await store.loadAll() == [first, second]) } @Test("同 id 再 upsert:替换不重复(keychain 持久化后仍只一条)") func upsertSameIdReplacesInsteadOfDuplicating() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost(name: "old") _ = try await store.upsert(host) let renamed = Host(id: host.id, name: "new", endpoint: host.endpoint) // Act let result = try await store.upsert(renamed) // Assert #expect(result == [renamed]) #expect(try await store.loadAll() == [renamed]) } @Test("remove 到空集合:走 delete 分支清掉整个 item") func removeLastHostTakesDeleteBranch() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) // Act let result = try await store.remove(id: host.id) // Assert #expect(result.isEmpty) #expect(shim.recordedDeletes.count == 1) #expect(try await store.loadAll().isEmpty) } @Test("remove 不存在的 id:集合不变、不 throw、零持久化调用") func removeUnknownIdIsExplicitNoOpWithoutPersisting() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) let addsBefore = shim.recordedAdds.count let updatesBefore = shim.recordedUpdates.count // Act let result = try await store.remove(id: UUID()) // Assert #expect(result == [host]) #expect(shim.recordedAdds.count == addsBefore) #expect(shim.recordedUpdates.count == updatesBefore) #expect(shim.recordedDeletes.isEmpty) } @Test("空 keychain loadAll:errSecItemNotFound 映射为空集合而非错误") func loadAllMapsItemNotFoundToEmptyCollection() async throws { // Arrange let store = KeychainHostStore(shim: FakeSecItemShim()) // Act / Assert #expect(try await store.loadAll().isEmpty) } @Test("delete 撞 errSecItemNotFound:视为成功(幂等删除)") func deleteItemNotFoundIsTreatedAsSuccess() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) shim.forceDeleteStatus(errSecItemNotFound) // Act let result = try await store.remove(id: host.id) // Assert #expect(result.isEmpty) } @Test("持久化跨实例:同一 shim 上的新 store 读回全部 host") func persistedHostsSurviveAcrossStoreInstances() async throws { // Arrange let shim = FakeSecItemShim() let a = Fixtures.makeHost(name: "a") let b = Fixtures.makeHost(name: "b", urlString: "https://mac.ts.net") let writer = KeychainHostStore(shim: shim) _ = try await writer.upsert(a) _ = try await writer.upsert(b) // Act let reader = KeychainHostStore(shim: shim) // Assert #expect(try await reader.loadAll() == [a, b]) } // MARK: - Explicit error-code mapping @Test("add 撞 -34018 errSecMissingEntitlement:映射为 .missingEntitlement") func addMissingEntitlementMapsToTypedError() async { // Arrange let shim = FakeSecItemShim() shim.forceAddStatus(errSecMissingEntitlement) let store = KeychainHostStore(shim: shim) // Act / Assert await #expect(throws: KeychainHostStoreError.missingEntitlement) { _ = try await store.upsert(Fixtures.makeHost()) } } @Test("copyMatching 意外错误码:映射为 .unexpectedStatus(原码)") func copyUnexpectedStatusMapsToTypedErrorWithCode() async { // Arrange let shim = FakeSecItemShim() shim.forceCopyStatus(errSecInteractionNotAllowed) let store = KeychainHostStore(shim: shim) // Act / Assert await #expect(throws: KeychainHostStoreError.unexpectedStatus(errSecInteractionNotAllowed)) { _ = try await store.loadAll() } } @Test("update 分支失败:映射为 .unexpectedStatus(原码)") func updateFailureMapsToTypedErrorWithCode() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) _ = try await store.upsert(Fixtures.makeHost()) shim.forceUpdateStatus(errSecAuthFailed) // Act / Assert await #expect(throws: KeychainHostStoreError.unexpectedStatus(errSecAuthFailed)) { _ = try await store.upsert(Fixtures.makeHost(name: "second")) } } @Test("delete 分支意外错误码:映射为 .unexpectedStatus(原码)") func deleteFailureMapsToTypedErrorWithCode() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) shim.forceDeleteStatus(errSecAuthFailed) // Act / Assert await #expect(throws: KeychainHostStoreError.unexpectedStatus(errSecAuthFailed)) { _ = try await store.remove(id: host.id) } } @Test("keychain 里的数据不是合法 [Host] JSON:映射为 .corruptedData,不 crash") func corruptKeychainPayloadMapsToTypedError() async { // Arrange let shim = FakeSecItemShim() shim.forceCopyData(Data("not-json".utf8)) let store = KeychainHostStore(shim: shim) // Act / Assert await #expect(throws: KeychainHostStoreError.corruptedData) { _ = try await store.loadAll() } } // MARK: - §5.3 attribute dictionaries, asserted verbatim @Test("§5.3 add 属性字典逐字校验:data-protection + AfterFirstUnlockThisDeviceOnly,无 synchronizable,无多余键") func addAttributeDictionaryIsExactlyThePolicySet() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim, service: "svc.test", account: "acct.test") // Act _ = try await store.upsert(Fixtures.makeHost()) // Assert let attrs = try #require(shim.recordedAdds.first) #expect(attrs[kSecClass as String] as? String == kSecClassGenericPassword as String) #expect(attrs[kSecAttrService as String] as? String == "svc.test") #expect(attrs[kSecAttrAccount as String] as? String == "acct.test") #expect(attrs[kSecUseDataProtectionKeychain as String] as? Bool == true) #expect(attrs[kSecAttrAccessible as String] as? String == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) #expect(attrs[kSecAttrSynchronizable as String] == nil) #expect(attrs[kSecValueData as String] is Data) #expect(attrs.count == 6) // 白名单外零多余键 —— verbatim 断言 } @Test("§5.3 update 分支字典:query 带 data-protection 不带数据;attributes 重申 accessible + 新数据") func updateDictionariesReassertProtectionPolicy() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) _ = try await store.upsert(Fixtures.makeHost(name: "first")) // Act _ = try await store.upsert(Fixtures.makeHost(name: "second", urlString: "http://10.1.1.1:3000")) // Assert let call = try #require(shim.recordedUpdates.first) #expect(call.query[kSecClass as String] as? String == kSecClassGenericPassword as String) #expect(call.query[kSecUseDataProtectionKeychain as String] as? Bool == true) #expect(call.query[kSecValueData as String] == nil) #expect(call.query.count == 4) // class + service + account + data-protection #expect(call.attributes[kSecAttrAccessible as String] as? String == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) #expect(call.attributes[kSecValueData as String] is Data) #expect(call.attributes.count == 2) } @Test("§5.3 copy 查询字典:base + returnData + matchLimitOne,共 6 键") func copyQueryDictionaryIsExactlyThePolicySet() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) // Act _ = try await store.loadAll() // Assert let query = try #require(shim.recordedCopies.first) #expect(query[kSecClass as String] as? String == kSecClassGenericPassword as String) #expect(query[kSecUseDataProtectionKeychain as String] as? Bool == true) #expect(query[kSecReturnData as String] as? Bool == true) #expect(query[kSecMatchLimit as String] as? String == kSecMatchLimitOne as String) #expect(query.count == 6) } @Test("§5.3 delete 查询字典:带 data-protection 的 base query,共 4 键") func deleteQueryDictionaryIsExactlyTheBaseQuery() async throws { // Arrange let shim = FakeSecItemShim() let store = KeychainHostStore(shim: shim) let host = Fixtures.makeHost() _ = try await store.upsert(host) // Act _ = try await store.remove(id: host.id) // Assert let query = try #require(shim.recordedDeletes.first) #expect(query[kSecClass as String] as? String == kSecClassGenericPassword as String) #expect(query[kSecUseDataProtectionKeychain as String] as? Bool == true) #expect(query.count == 4) }