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,387 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
@testable import APIClient
/// T-iOS-8 · plan §3.4
/// `GET /live-sessions` Origin +
/// WS attach(null) adopt attached ** DELETE kill Origin**
/// `PairingError` clock/deadlineFakeClock
///
/// §3.4 2026-07-04 `Result<HostEndpoint, PairingError>`
///Host{id,name} T-iOS-12 VM `@testable`
/// `runPairingProbeCore` FakeClock
/// `runPairingProbe`ContinuousClock + Tunables.pairingProbeTimeout
/// deadline 线
struct PairingProbeTests {
private static let base = "http://192.168.1.5:3000"
private static let sessionIdString = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
private static let attachedFrame =
#"{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"#
private struct Fixture {
let endpoint: HostEndpoint
let http: FakeHTTPTransport
let ws: FakeTransport
let liveSessionsURL: URL
let killURL: URL
}
private func makeFixture(base: String = PairingProbeTests.base) throws -> Fixture {
let baseURL = try #require(URL(string: base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
return Fixture(
endpoint: endpoint,
http: FakeHTTPTransport(),
ws: FakeTransport(),
liveSessionsURL: try #require(URL(string: base + "/live-sessions")),
killURL: try #require(URL(string: base + "/live-sessions/\(Self.sessionIdString)"))
)
}
/// timeout: nil race,
private func runProbe(_ fixture: Fixture, timeout: Duration? = nil,
clock: FakeClock = FakeClock()) async -> Result<HostEndpoint, PairingError> {
await runPairingProbeCore(
endpoint: fixture.endpoint, http: fixture.http, ws: fixture.ws,
clock: clock, timeout: timeout
)
}
// MARK: -
@Test("探针①连接拒绝 → hostUnreachable,且不触碰 WS(不发任何升级请求)")
func stepOneConnectionRefusedMapsToHostUnreachable() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueFailure(
url: fixture.liveSessionsURL, error: URLError(.cannotConnectToHost)
)
// Act
let result = await runProbe(fixture)
// Assert
guard case .failure(.hostUnreachable) = result else {
Issue.record("expected hostUnreachable, got \(result)")
return
}
#expect(await fixture.ws.connectAttempts.isEmpty)
}
@Test("探针①返回 HTML → httpOkButNotWebTerminal(端口对吗?)")
func stepOneHTMLBodyMapsToNotWebTerminal() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
url: fixture.liveSessionsURL,
body: Data("<html><body>router admin</body></html>".utf8)
)
// Act + Assert
#expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
}
@Test("探针① 404 → httpOkButNotWebTerminal(HTTP 有应答但不是 web-terminal)")
func stepOne404MapsToNotWebTerminal() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, status: 404)
// Act + Assert
#expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
}
@Test("探针① ATS 拦明文(-1022) → atsBlocked(host)")
func stepOneATSBlockMapsToAtsBlocked() async throws {
// Arrange
let fixture = try makeFixture()
let ats = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorAppTransportSecurityRequiresSecureConnection
)
await fixture.http.queueFailure(url: fixture.liveSessionsURL, error: ats)
// Act + Assert
#expect(await runProbe(fixture) == .failure(.atsBlocked(host: "192.168.1.5")))
}
@Test("探针① POSIX Network-down + LAN IP → localNetworkDenied(本地网络权限被拒)")
func stepOnePosixNetworkDownOnLANMapsToLocalNetworkDenied() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueFailure(
url: fixture.liveSessionsURL, error: Self.networkDownError()
)
// Act + Assert
#expect(await runProbe(fixture) == .failure(.localNetworkDenied))
}
@Test("探针① POSIX Network-down + 公网 host → hostUnreachable(不误报本地网络权限)")
func stepOnePosixNetworkDownOnPublicHostMapsToHostUnreachable() async throws {
// Arrange
let publicBase = "http://203.0.113.7:3000"
let fixture = try makeFixture(base: publicBase)
await fixture.http.queueFailure(
url: fixture.liveSessionsURL, error: Self.networkDownError()
)
// Act
let result = await runProbe(fixture)
// Assert
guard case .failure(.hostUnreachable) = result else {
Issue.record("expected hostUnreachable, got \(result)")
return
}
}
@Test("探针① TLS 失败(-1200) → tlsFailure;URLSession 层超时(-1001) → timeout")
func stepOneTLSAndURLTimeoutClassification() async throws {
// Arrange TLS
let tlsFixture = try makeFixture()
await tlsFixture.http.queueFailure(
url: tlsFixture.liveSessionsURL, error: URLError(.secureConnectionFailed)
)
// Arrange URLSession
let timeoutFixture = try makeFixture()
await timeoutFixture.http.queueFailure(
url: timeoutFixture.liveSessionsURL, error: URLError(.timedOut)
)
// Act + Assert
#expect(await runProbe(tlsFixture) == .failure(.tlsFailure))
#expect(await runProbe(timeoutFixture) == .failure(.timeout))
}
// MARK: -
@Test("探针② WS 升级被拒(401) → originRejected,hint 含 ALLOWED_ORIGINS=<拨号 origin>")
func stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() async throws {
// Arrange , (401 )
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.ws.scriptConnectFailure()
// Act
let result = await runProbe(fixture)
// Assert
guard case .failure(.originRejected(let hint)) = result else {
Issue.record("expected originRejected, got \(result)")
return
}
#expect(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))
#expect(!hint.contains(":443"))
}
@Test("originRejected hint 对 https 默认端口不写 :443(与拨号 URL 一致,无端口迷信)")
func originRejectedHintOmitsDefaultPortForHTTPS() async throws {
// Arrange
let fixture = try makeFixture(base: "https://mac.tailnet.ts.net")
await fixture.http.queueSuccess(
url: try #require(URL(string: "https://mac.tailnet.ts.net/live-sessions")),
body: Data("[]".utf8)
)
await fixture.ws.scriptConnectFailure()
// Act
let result = await runProbe(fixture)
// Assert
guard case .failure(.originRejected(let hint)) = result else {
Issue.record("expected originRejected, got \(result)")
return
}
#expect(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
#expect(!hint.contains(":443"))
}
@Test("探针②流在 attached 前结束 → httpOkButNotWebTerminal(升级已成功,非 Origin 问题)")
func stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() async throws {
// Arrange finish connect
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.ws.finishFrames()
// Act + Assert
#expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
}
// MARK: - + kill
@Test("探针全通 → success,attach 首帧 sessionId 为显式 null,attached 后必发带 Origin 的 kill(不留孤儿会话)")
func fullProbeSuccessKillsProbeSessionWithOrigin() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
await fixture.ws.emit(frame: Self.attachedFrame)
// Act
let result = await runProbe(fixture)
// Assert
#expect(result == .success(fixture.endpoint))
// Assert attach(null) WS ,sessionId null
let sentFrames = await fixture.ws.sentFrames
#expect(sentFrames.count == 1)
let frame = try #require(sentFrames.first)
let object = try #require(
try JSONSerialization.jsonObject(with: Data(frame.utf8)) as? [String: Any]
)
#expect(object["type"] as? String == "attach")
#expect(object["sessionId"] is NSNull)
// Assert kill attached , Origin
let requests = await fixture.http.recordedRequests
#expect(requests.count == 2)
let kill = try #require(requests.last)
#expect(kill.httpMethod == "DELETE")
#expect(kill.url == fixture.killURL)
#expect(kill.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
// Assert WS ()
#expect(await fixture.ws.closeCallCount == 1)
}
@Test("公开包装 runPairingProbe:全通 → success(endpoint)(§3.4 裁定签名;默认 deadline 走 Tunables.pairingProbeTimeout,快路径不触发)")
func publicWrapperHappyPathReturnsEndpoint() async throws {
// Arrange ;fakes ,ContinuousClock race
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
await fixture.ws.emit(frame: Self.attachedFrame)
// Act
let result = await runPairingProbe(
endpoint: fixture.endpoint, http: fixture.http, ws: fixture.ws
)
// Assert
#expect(result == .success(fixture.endpoint))
#expect(await fixture.ws.closeCallCount == 1)
}
@Test("attached 前收到 output 帧仍成功(不可信服务器容忍:跳过非 attached 帧)")
func outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
await fixture.ws.emit(frame: #"{"type":"output","data":"\u001b[0mreplay"}"#)
await fixture.ws.emit(frame: Self.attachedFrame)
// Act + Assert
#expect(await runProbe(fixture) == .success(fixture.endpoint))
}
@Test("kill 被 G 守卫拒(403) → originRejected(HTTP 侧 Origin 校验也是配对必验项)")
func killRejectedByOriginGuardMapsToOriginRejected() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 403)
await fixture.ws.emit(frame: Self.attachedFrame)
// Act
let result = await runProbe(fixture)
// Assert
guard case .failure(.originRejected) = result else {
Issue.record("expected originRejected, got \(result)")
return
}
}
@Test("kill 返回 404(会话已自行退出) → 仍算全通(目标态就是会话不存在)")
func killReturning404StillCountsAsSuccess() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 404)
await fixture.ws.emit(frame: Self.attachedFrame)
// Act + Assert
#expect(await runProbe(fixture) == .success(fixture.endpoint))
}
// MARK: - classify POSIX
@Test("POSIX ETIMEDOUT → timeout;POSIX ECONNREFUSED → hostUnreachable")
func posixTimeoutAndRefusedClassification() async throws {
// Arrange
let timeoutFixture = try makeFixture()
await timeoutFixture.http.queueFailure(
url: timeoutFixture.liveSessionsURL,
error: NSError(domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ETIMEDOUT.rawValue))
)
let refusedFixture = try makeFixture()
await refusedFixture.http.queueFailure(
url: refusedFixture.liveSessionsURL,
error: NSError(domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ECONNREFUSED.rawValue))
)
// Act
let timeoutResult = await runProbe(timeoutFixture)
let refusedResult = await runProbe(refusedFixture)
// Assert
#expect(timeoutResult == .failure(.timeout))
guard case .failure(.hostUnreachable) = refusedResult else {
Issue.record("expected hostUnreachable, got \(refusedResult)")
return
}
}
@Test("LAN/私网 host 判定表:RFC1918+link-local+loopback+CGNAT+.local 为真,公网/畸形为假")
func privateOrLocalHostPredicateTable() {
// Assert LAN (localNetworkDenied )
let lanHosts = [
"localhost", "mac-mini.local", "10.0.0.5", "172.20.10.1",
"192.168.0.9", "169.254.1.1", "100.100.1.1", "127.0.0.1",
]
for host in lanHosts {
#expect(PairingError.isPrivateOrLocalHost(host), "\(host) 应判为私网级")
}
// Assert LAN()
let publicHosts = [
"8.8.8.8", "203.0.113.7", "mac.tailnet.ts.net",
"256.1.1.1", "172.32.0.1", "100.128.0.1", "1.2.3",
]
for host in publicHosts {
#expect(!PairingError.isPrivateOrLocalHost(host), "\(host) 不应判为私网级")
}
}
// MARK: - FakeClock
@Test("超时:探针卡在等待 attached 时,注入的 FakeClock 到期 → .timeout")
func probeTimesOutViaInjectedClockDeadline() async throws {
// Arrange ; attached
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
let clock = FakeClock()
let probeTimeout: Duration = .seconds(10)
// Act deadline sleeper (),
async let result = runProbe(fixture, timeout: probeTimeout, clock: clock)
await clock.waitForSleepers(count: 1)
clock.advance(by: probeTimeout)
// Assert
#expect(await result == .failure(.timeout))
}
// MARK: - fixtures
private static func networkDownError() -> NSError {
let posixDown = NSError(
domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ENETDOWN.rawValue)
)
return NSError(
domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet,
userInfo: [NSUnderlyingErrorKey: posixDown]
)
}
}