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:
23
ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift
Normal file
23
ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// A paired web-terminal host (frozen contract, plan §3.3). Immutable
|
||||
/// snapshot: identity (`id`) and dial info (`endpoint`) are fixed at creation;
|
||||
/// "renaming" a host means upserting a new value with the same `id`.
|
||||
///
|
||||
/// Note: on macOS this shadows `Foundation.Host` (NSHost) inside this module;
|
||||
/// downstream test/app targets that import Foundation should alias
|
||||
/// `typealias Host = HostRegistry.Host` or qualify.
|
||||
public struct Host: Sendable, Equatable, Codable, Identifiable {
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
/// Single point of Origin/wsURL derivation — from WireProtocol, never
|
||||
/// redeclared here (plan §6 rule 1).
|
||||
public let endpoint: HostEndpoint
|
||||
|
||||
public init(id: UUID, name: String, endpoint: HostEndpoint) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.endpoint = endpoint
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
/// Frozen contract (plan §3.3). Implementations: `KeychainHostStore` (real)
|
||||
/// and `InMemoryHostStore` (in-Sources double for this package's tests and
|
||||
/// the App-layer VM tests). Immutable style: mutations return the NEW
|
||||
/// collection instead of mutating shared state in place.
|
||||
public protocol HostStore: Sendable {
|
||||
func loadAll() async throws -> [Host]
|
||||
/// Insert, or replace the host with the same `id` (position preserved).
|
||||
/// Returns the new collection.
|
||||
func upsert(_ host: Host) async throws -> [Host]
|
||||
/// Removing an unknown `id` is an explicit no-op: returns the unchanged
|
||||
/// collection, never throws for "not found".
|
||||
func remove(id: UUID) async throws -> [Host]
|
||||
}
|
||||
|
||||
// Pure collection transforms shared by all HostStore implementations (DRY);
|
||||
// never mutate `self`, always return a fresh array.
|
||||
extension [Host] {
|
||||
func upserting(_ host: Host) -> [Host] {
|
||||
contains(where: { $0.id == host.id })
|
||||
? map { $0.id == host.id ? host : $0 }
|
||||
: self + [host]
|
||||
}
|
||||
|
||||
func removing(id: UUID) -> [Host] {
|
||||
filter { $0.id != id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
/// In-memory `HostStore`. Lives in Sources (not Tests) on purpose — plan
|
||||
/// T-iOS-7: it doubles for the keychain store in this package's contract
|
||||
/// tests AND in the App layer's ViewModel tests (which import HostRegistry).
|
||||
///
|
||||
/// Actor-isolated so concurrent upserts/removes serialize; the state is a
|
||||
/// value-type snapshot replaced wholesale on every change (no in-place
|
||||
/// mutation of shared references).
|
||||
public actor InMemoryHostStore: HostStore {
|
||||
private var hosts: [Host]
|
||||
|
||||
public init(hosts: [Host] = []) {
|
||||
self.hosts = hosts
|
||||
}
|
||||
|
||||
public func loadAll() async throws -> [Host] {
|
||||
hosts
|
||||
}
|
||||
|
||||
public func upsert(_ host: Host) async throws -> [Host] {
|
||||
let updated = hosts.upserting(host)
|
||||
hosts = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
public func remove(id: UUID) async throws -> [Host] {
|
||||
let updated = hosts.removing(id: id)
|
||||
hosts = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Explicit error taxonomy for keychain-backed persistence (plan T-iOS-7
|
||||
/// "错误码显式映射"). `errSecDuplicateItem` and `errSecItemNotFound` are
|
||||
/// BRANCHES (add→update fallback / empty list / idempotent delete), never
|
||||
/// surfaced as errors.
|
||||
public enum KeychainHostStoreError: Error, Equatable {
|
||||
/// -34018 `errSecMissingEntitlement`: the data-protection keychain is
|
||||
/// unavailable to unsigned binaries (e.g. `swift test`) — a signal to
|
||||
/// inject a fake `SecItemShim`, not a user-facing failure.
|
||||
case missingEntitlement
|
||||
/// Stored payload is not a decodable `[Host]` — treated as an explicit
|
||||
/// error, never a crash (defensive at the storage boundary).
|
||||
case corruptedData
|
||||
/// `[Host]` failed to JSON-encode (practically unreachable; kept for a
|
||||
/// complete, explicit taxonomy on the write path).
|
||||
case encodingFailed
|
||||
case unexpectedStatus(OSStatus)
|
||||
|
||||
init(status: OSStatus) {
|
||||
self = status == errSecMissingEntitlement ? .missingEntitlement : .unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keychain-backed `HostStore`: the whole host list is ONE generic-password
|
||||
/// item (JSON `[Host]`) under `service`/`account`. An actor so the
|
||||
/// read-modify-write in `upsert`/`remove` serializes.
|
||||
///
|
||||
/// All SecItem dictionaries come from `KeychainItemSpec` — the §5.3 single
|
||||
/// source; building an attribute dict anywhere else is a review CRITICAL.
|
||||
public actor KeychainHostStore: HostStore {
|
||||
public static let defaultService = "com.yaojia.webterm.host-registry"
|
||||
public static let defaultAccount = "hosts"
|
||||
|
||||
private let shim: any SecItemShim
|
||||
private let spec: KeychainItemSpec
|
||||
|
||||
public init(
|
||||
shim: any SecItemShim = LiveSecItemShim(),
|
||||
service: String = KeychainHostStore.defaultService,
|
||||
account: String = KeychainHostStore.defaultAccount
|
||||
) {
|
||||
self.shim = shim
|
||||
self.spec = KeychainItemSpec(service: service, account: account)
|
||||
}
|
||||
|
||||
public func loadAll() async throws -> [Host] {
|
||||
try readHosts()
|
||||
}
|
||||
|
||||
public func upsert(_ host: Host) async throws -> [Host] {
|
||||
let updated = try readHosts().upserting(host)
|
||||
try persist(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
public func remove(id: UUID) async throws -> [Host] {
|
||||
let current = try readHosts()
|
||||
let updated = current.removing(id: id)
|
||||
guard updated.count != current.count else {
|
||||
return current // explicit no-op: unknown id, nothing persisted
|
||||
}
|
||||
try persist(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
// MARK: - Keychain I/O (dictionaries built solely by KeychainItemSpec)
|
||||
|
||||
private func readHosts() throws -> [Host] {
|
||||
let (status, data) = shim.copyMatching(spec.copyQuery())
|
||||
if status == errSecItemNotFound { return [] }
|
||||
guard status == errSecSuccess else { throw KeychainHostStoreError(status: status) }
|
||||
guard let data, let hosts = try? JSONDecoder().decode([Host].self, from: data) else {
|
||||
throw KeychainHostStoreError.corruptedData
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
private func persist(_ hosts: [Host]) throws {
|
||||
if hosts.isEmpty {
|
||||
try deleteItem()
|
||||
return
|
||||
}
|
||||
let data = try encodeHosts(hosts)
|
||||
let addStatus = shim.add(spec.addAttributes(data: data))
|
||||
if addStatus == errSecSuccess { return }
|
||||
guard addStatus == errSecDuplicateItem else {
|
||||
throw KeychainHostStoreError(status: addStatus)
|
||||
}
|
||||
let updateStatus = shim.update(
|
||||
query: spec.baseQuery(),
|
||||
attributesToUpdate: spec.updateAttributes(data: data)
|
||||
)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw KeychainHostStoreError(status: updateStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItem() throws {
|
||||
let status = shim.delete(spec.baseQuery())
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainHostStoreError(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeHosts(_ hosts: [Host]) throws -> Data {
|
||||
do {
|
||||
return try JSONEncoder().encode(hosts)
|
||||
} catch {
|
||||
throw KeychainHostStoreError.encodingFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
/// Frozen contract (plan §3.3). NON-SECRET per-host UI state only — anything
|
||||
/// sensitive belongs in `KeychainHostStore` (§5.3 split: Keychain = secrets,
|
||||
/// UserDefaults = prefs).
|
||||
public protocol LastSessionStore: Sendable {
|
||||
func lastSessionId(host: UUID) -> UUID?
|
||||
func setLastSessionId(_ id: UUID?, host: UUID)
|
||||
}
|
||||
|
||||
/// UserDefaults-backed implementation with an injectable suite for tests.
|
||||
/// `@unchecked Sendable`: `UserDefaults` carries no Sendable annotation in
|
||||
/// the SDK but is documented thread-safe ("NSUserDefaults is thread-safe"),
|
||||
/// and this wrapper adds no mutable state of its own.
|
||||
public struct UserDefaultsLastSessionStore: LastSessionStore, @unchecked Sendable {
|
||||
private static let keyPrefix = "lastSessionId."
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public func lastSessionId(host: UUID) -> UUID? {
|
||||
guard let raw = defaults.string(forKey: Self.key(host)) else { return nil }
|
||||
// Stored value crosses a storage boundary — validate, never trust:
|
||||
// garbage reads back as nil instead of crashing or misrouting.
|
||||
return UUID(uuidString: raw)
|
||||
}
|
||||
|
||||
public func setLastSessionId(_ id: UUID?, host: UUID) {
|
||||
guard let id else {
|
||||
defaults.removeObject(forKey: Self.key(host))
|
||||
return
|
||||
}
|
||||
defaults.set(id.uuidString, forKey: Self.key(host))
|
||||
}
|
||||
|
||||
private static func key(_ host: UUID) -> String {
|
||||
keyPrefix + host.uuidString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Testability seam over the four `SecItem*` C calls (plan §3.3 / T-iOS-7).
|
||||
///
|
||||
/// Why a seam: unsigned `swift test` binaries get `errSecMissingEntitlement`
|
||||
/// (-34018) from the data-protection keychain, so unit tests inject a fake
|
||||
/// conforming to this protocol and assert the store's dictionaries verbatim;
|
||||
/// the live shim only runs inside the signed simulator/app pass (T-iOS-15/16).
|
||||
public protocol SecItemShim: Sendable {
|
||||
func add(_ attributes: [String: any Sendable]) -> OSStatus
|
||||
func update(query: [String: any Sendable],
|
||||
attributesToUpdate: [String: any Sendable]) -> OSStatus
|
||||
func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?)
|
||||
func delete(_ query: [String: any Sendable]) -> OSStatus
|
||||
}
|
||||
|
||||
/// Live passthrough to Security.framework. Deliberately logic-free: every
|
||||
/// attribute — including the §5.3-mandated protection keys — is built by
|
||||
/// `KeychainItemSpec` below, so there is exactly ONE place to audit and to
|
||||
/// unit-test. Adding attribute logic here would split that single source.
|
||||
public struct LiveSecItemShim: SecItemShim {
|
||||
public init() {}
|
||||
|
||||
public func add(_ attributes: [String: any Sendable]) -> OSStatus {
|
||||
SecItemAdd(attributes as [String: Any] as CFDictionary, nil)
|
||||
}
|
||||
|
||||
public func update(query: [String: any Sendable],
|
||||
attributesToUpdate: [String: any Sendable]) -> OSStatus {
|
||||
SecItemUpdate(query as [String: Any] as CFDictionary,
|
||||
attributesToUpdate as [String: Any] as CFDictionary)
|
||||
}
|
||||
|
||||
public func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?) {
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as [String: Any] as CFDictionary, &result)
|
||||
return (status, result as? Data)
|
||||
}
|
||||
|
||||
public func delete(_ query: [String: any Sendable]) -> OSStatus {
|
||||
SecItemDelete(query as [String: Any] as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// §5.3 single source of truth for every keychain dictionary the store sends
|
||||
/// through the shim. One wrong attribute here = credentials leave the device
|
||||
/// with backups — KeychainHostStoreTests asserts these dictionaries verbatim
|
||||
/// (exact key sets), and the signed simulator pass (T-iOS-15/16) verifies the
|
||||
/// real item's attributes end-to-end.
|
||||
struct KeychainItemSpec: Sendable {
|
||||
let service: String
|
||||
let account: String
|
||||
|
||||
/// Identifies the item in every call. `kSecUseDataProtectionKeychain`
|
||||
/// pins the iOS-style data-protection keychain on macOS too (on iOS it
|
||||
/// is implicit); omitting it would silently fall back to the legacy
|
||||
/// file keychain on Mac and void the §5.3 semantics.
|
||||
func baseQuery() -> [String: any Sendable] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword as String,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecUseDataProtectionKeychain as String: true,
|
||||
]
|
||||
}
|
||||
|
||||
func copyQuery() -> [String: any Sendable] {
|
||||
merged(baseQuery(), [
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne as String,
|
||||
])
|
||||
}
|
||||
|
||||
/// `AfterFirstUnlockThisDeviceOnly`: readable in background after first
|
||||
/// unlock (reconnect/UI needs), never migrates to another device or into
|
||||
/// backups; no `kSecAttrSynchronizable` → never iCloud-synced (§5.3).
|
||||
func addAttributes(data: Data) -> [String: any Sendable] {
|
||||
merged(baseQuery(), updateAttributes(data: data))
|
||||
}
|
||||
|
||||
/// Update re-asserts `kSecAttrAccessible` so a pre-existing item can
|
||||
/// never retain a weaker protection class than the current policy.
|
||||
func updateAttributes(data: Data) -> [String: any Sendable] {
|
||||
[
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
|
||||
kSecValueData as String: data,
|
||||
]
|
||||
}
|
||||
|
||||
private func merged(
|
||||
_ base: [String: any Sendable],
|
||||
_ extra: [String: any Sendable]
|
||||
) -> [String: any Sendable] {
|
||||
base.merging(extra) { _, new in new }
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user