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/deadline(FakeClock,零真实等待)。 /// /// 注:§3.4 经 2026-07-04 契约裁定改为返回 `Result` ///(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 { 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("router admin".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] ) } }