Files
web-terminal/ios/Packages/APIClient/Tests/APIClientTests/PairingProbeTests.swift
Yaojia Wang 95438cdc12 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
2026-07-04 21:53:41 +02:00

388 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]
)
}
}