T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
293 lines
10 KiB
Swift
293 lines
10 KiB
Swift
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)
|
|
}
|