feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe

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
This commit is contained in:
Yaojia Wang
2026-07-04 21:53:41 +02:00
parent 2ab93c9682
commit 95438cdc12
32 changed files with 3772 additions and 4 deletions

View File

@@ -0,0 +1,97 @@
import Foundation
import Security
import os
import HostRegistry
/// In-memory keychain double behind the `SecItemShim` seam (plan §3.3):
/// unsigned `swift test` binaries get errSecMissingEntitlement (-34018) from
/// the real data-protection keychain, so store logic is tested against this.
///
/// Behavioral single-slot model (the store uses exactly one service+account):
/// - add: duplicate if a value is stored; otherwise stores kSecValueData
/// - update: itemNotFound when empty; otherwise replaces the stored value
/// - copyMatching: itemNotFound when empty; otherwise returns the stored value
/// - delete: itemNotFound when empty; otherwise clears
///
/// `force*` knobs override the next status/result (forced statuses do NOT
/// touch storage the fake stays dumb). All calls are recorded verbatim so
/// tests can assert the §5.3 attribute dictionaries character-for-character.
final class FakeSecItemShim: SecItemShim, Sendable {
struct UpdateCall: Sendable {
let query: [String: any Sendable]
let attributes: [String: any Sendable]
}
private struct State: Sendable {
var storedData: Data?
var addCalls: [[String: any Sendable]] = []
var updateCalls: [UpdateCall] = []
var copyCalls: [[String: any Sendable]] = []
var deleteCalls: [[String: any Sendable]] = []
var forcedAddStatus: OSStatus?
var forcedUpdateStatus: OSStatus?
var forcedCopyStatus: OSStatus?
var forcedDeleteStatus: OSStatus?
var forcedCopyData: Data?
}
private let state = OSAllocatedUnfairLock(initialState: State())
// MARK: - SecItemShim
func add(_ attributes: [String: any Sendable]) -> OSStatus {
state.withLock { s in
s.addCalls.append(attributes)
if let forced = s.forcedAddStatus { return forced }
guard s.storedData == nil else { return errSecDuplicateItem }
s.storedData = attributes[kSecValueData as String] as? Data
return errSecSuccess
}
}
func update(query: [String: any Sendable],
attributesToUpdate: [String: any Sendable]) -> OSStatus {
state.withLock { s in
s.updateCalls.append(UpdateCall(query: query, attributes: attributesToUpdate))
if let forced = s.forcedUpdateStatus { return forced }
guard s.storedData != nil else { return errSecItemNotFound }
s.storedData = attributesToUpdate[kSecValueData as String] as? Data
return errSecSuccess
}
}
func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?) {
state.withLock { s in
s.copyCalls.append(query)
if let forced = s.forcedCopyStatus { return (forced, nil) }
if let forcedData = s.forcedCopyData { return (errSecSuccess, forcedData) }
guard let data = s.storedData else { return (errSecItemNotFound, nil) }
return (errSecSuccess, data)
}
}
func delete(_ query: [String: any Sendable]) -> OSStatus {
state.withLock { s in
s.deleteCalls.append(query)
if let forced = s.forcedDeleteStatus { return forced }
guard s.storedData != nil else { return errSecItemNotFound }
s.storedData = nil
return errSecSuccess
}
}
// MARK: - Test controls
func forceAddStatus(_ status: OSStatus) { state.withLock { $0.forcedAddStatus = status } }
func forceUpdateStatus(_ status: OSStatus) { state.withLock { $0.forcedUpdateStatus = status } }
func forceCopyStatus(_ status: OSStatus) { state.withLock { $0.forcedCopyStatus = status } }
func forceDeleteStatus(_ status: OSStatus) { state.withLock { $0.forcedDeleteStatus = status } }
func forceCopyData(_ data: Data) { state.withLock { $0.forcedCopyData = data } }
// MARK: - Recorded calls
var recordedAdds: [[String: any Sendable]] { state.withLock { $0.addCalls } }
var recordedUpdates: [UpdateCall] { state.withLock { $0.updateCalls } }
var recordedCopies: [[String: any Sendable]] { state.withLock { $0.copyCalls } }
var recordedDeletes: [[String: any Sendable]] { state.withLock { $0.deleteCalls } }
}

View File

@@ -0,0 +1,28 @@
import Foundation
import Testing
import WireProtocol
import HostRegistry
@Test("Host Codable 往返:id/name/endpoint 全保真(originHeader 重派生一致)")
func hostCodableRoundTripPreservesAllFields() throws {
// Arrange
let host = Fixtures.makeHost(name: "studio", urlString: "https://mac.ts.net:8443")
// Act
let data = try JSONEncoder().encode(host)
let decoded = try JSONDecoder().decode(Host.self, from: data)
// Assert
#expect(decoded == host)
#expect(decoded.endpoint.originHeader == "https://mac.ts.net:8443")
}
@Test("Identifiable:Host.id 即协议 id")
func hostIdentifiableIdIsItsOwnId() {
// Arrange / Act
let host = Fixtures.makeHost()
// Assert
let identifiable: any Identifiable<UUID> = host
#expect(identifiable.id == host.id)
}

View File

@@ -0,0 +1,77 @@
import Foundation
import Testing
import HostRegistry
// HostStore protocol contract, tested against the in-Sources InMemory double
// (plan T-iOS-7: " InMemory ").
@Test("upsert 新 host:返回的新集合含它,loadAll 一致")
func upsertNewHostAppearsInReturnedCollectionAndLoadAll() async throws {
// Arrange
let store = InMemoryHostStore()
let host = Fixtures.makeHost()
// Act
let result = try await store.upsert(host)
// Assert
#expect(result == [host])
#expect(try await store.loadAll() == [host])
}
@Test("同 id 再 upsert:替换不重复,且保持原位置")
func upsertSameIdReplacesWithoutDuplicatingAndKeepsPosition() async throws {
// Arrange
let first = Fixtures.makeHost(name: "old-name")
let second = Fixtures.makeHost(name: "other", urlString: "https://mac.ts.net")
let store = InMemoryHostStore(hosts: [first, second])
let renamed = Host(id: first.id, name: "new-name", endpoint: first.endpoint)
// Act
let result = try await store.upsert(renamed)
// Assert
#expect(result == [renamed, second])
#expect(try await store.loadAll() == [renamed, second])
}
@Test("remove 已存在 id:集合不再含它")
func removeExistingIdDropsItFromCollection() async throws {
// Arrange
let keep = Fixtures.makeHost(name: "keep")
let doomed = Fixtures.makeHost(name: "doomed", urlString: "http://10.0.0.7:3000")
let store = InMemoryHostStore(hosts: [keep, doomed])
// Act
let result = try await store.remove(id: doomed.id)
// Assert
#expect(result == [keep])
#expect(try await store.loadAll() == [keep])
}
@Test("remove 不存在的 id:集合不变、不 throw(显式返回原集合)")
func removeUnknownIdReturnsUnchangedCollectionWithoutThrowing() async throws {
// Arrange
let host = Fixtures.makeHost()
let store = InMemoryHostStore(hosts: [host])
// Act
let result = try await store.remove(id: UUID())
// Assert
#expect(result == [host])
#expect(try await store.loadAll() == [host])
}
@Test("init 种子集合:loadAll 原样返回")
func initSeedsCollectionVerbatim() async throws {
// Arrange
let hosts = [Fixtures.makeHost(name: "a"), Fixtures.makeHost(name: "b")]
// Act
let store = InMemoryHostStore(hosts: hosts)
// Assert
#expect(try await store.loadAll() == hosts)
}

View File

@@ -0,0 +1,292 @@
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)
}

View File

@@ -0,0 +1,91 @@
import Foundation
import Testing
import HostRegistry
// UserDefaultsLastSessionStore over an injectable, per-test-unique suite
// (plan §3.3: ;lastSessionId /)
@Test("lastSessionId 存取:set 后读回同一 UUID")
func setThenGetReturnsSameSessionId() throws {
// Arrange
let suiteName = Fixtures.makeSuiteName()
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = UserDefaultsLastSessionStore(defaults: defaults)
let host = UUID()
let session = UUID()
// Act
store.setLastSessionId(session, host: host)
// Assert
#expect(store.lastSessionId(host: host) == session)
}
@Test("未设置过的 host:lastSessionId 返回 nil")
func unknownHostReturnsNil() throws {
// Arrange
let suiteName = Fixtures.makeSuiteName()
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = UserDefaultsLastSessionStore(defaults: defaults)
// Act / Assert
#expect(store.lastSessionId(host: UUID()) == nil)
}
@Test("set nil:清除既有 lastSessionId")
func settingNilClearsStoredSessionId() throws {
// Arrange
let suiteName = Fixtures.makeSuiteName()
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = UserDefaultsLastSessionStore(defaults: defaults)
let host = UUID()
store.setLastSessionId(UUID(), host: host)
// Act
store.setLastSessionId(nil, host: host)
// Assert
#expect(store.lastSessionId(host: host) == nil)
}
@Test("不同 host 键隔离:互不覆盖")
func distinctHostsDoNotCollide() throws {
// Arrange
let suiteName = Fixtures.makeSuiteName()
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = UserDefaultsLastSessionStore(defaults: defaults)
let hostA = UUID()
let hostB = UUID()
let sessionA = UUID()
let sessionB = UUID()
// Act
store.setLastSessionId(sessionA, host: hostA)
store.setLastSessionId(sessionB, host: hostB)
// Assert
#expect(store.lastSessionId(host: hostA) == sessionA)
#expect(store.lastSessionId(host: hostB) == sessionB)
}
@Test("UserDefaults 中的非 UUID 垃圾值:读回 nil 而非 crash(防御性解析)")
func garbageStoredValueReadsBackAsNil() throws {
// Arrange discover the store's key via the suite's own domain, then poison it
let suiteName = Fixtures.makeSuiteName()
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = UserDefaultsLastSessionStore(defaults: defaults)
let host = UUID()
store.setLastSessionId(UUID(), host: host)
let key = try #require(defaults.persistentDomain(forName: suiteName)?.keys.first)
// Act
defaults.set("not-a-uuid", forKey: key)
// Assert
#expect(store.lastSessionId(host: host) == nil)
}

View File

@@ -0,0 +1,28 @@
import Foundation
import WireProtocol
import HostRegistry
/// On macOS `Foundation.Host` (NSHost) shadows the package type in test
/// files that import Foundation pin resolution for the whole test target.
typealias Host = HostRegistry.Host
/// Shared fixtures for HostRegistryTests. Pure helpers only no state.
enum Fixtures {
/// Builds a valid Host; traps loudly if the fixture URL is malformed
/// (a broken fixture must fail the suite, not silently skip assertions).
static func makeHost(
name: String = "mac-studio",
urlString: String = "http://192.168.1.5:3000",
id: UUID = UUID()
) -> Host {
guard let url = URL(string: urlString), let endpoint = HostEndpoint(baseURL: url) else {
fatalError("test fixture URL invalid: \(urlString)")
}
return Host(id: id, name: name, endpoint: endpoint)
}
/// Unique UserDefaults suite name per test isolates parallel tests.
static func makeSuiteName() -> String {
"HostRegistryTests." + UUID().uuidString
}
}