// // OriginSpikeTests.swift — T-iOS-2 Day-1 spike(自动化部分 ①–④) // // 对本仓库的【真 Node 服务器】实测 URLSessionWebSocketTask 的两个 make-or-break // 平台事实(plan §7 T-iOS-2): // ① 无 Origin 的 WS 升级被 401 拒绝(src/server.ts:646-651,默认拒空 Origin) // ② 自定义 `Origin` header 能被 URLSessionWebSocketTask 发出并精确匹配白名单 // → 升级成功、attach(null) 收到 attached(这是 MED 置信度事实的一锤定音) // ③ ring-buffer 回放是单帧全量(src/session/session.ts attachWs → buffer.snapshot()): // 默认 maximumMessageSize=1MiB 收不下 >1MiB 回放帧(withKnownIssue 记录复现), // 设 16 MiB 后成功;对抗变体:ESC/C0 密集输出(JSON \uXXXX 转义膨胀)仍成功 // ④ 端口失配的 Origin(http://127.0.0.1:9999)→ 401(src/http/origin.ts:47-51 // 端口精确比对;注意 :443 这类默认端口会被 new URL() 双向规范化,不是失配案例) // // 运行方式(可复现): // A. 默认自举:`swift test --package-path ios/IntegrationTests` // 自动从本仓库 spawn `node_modules/.bin/tsx src/server.ts`(repo 根由 // #filePath 推导,可用 WEBTERM_REPO_ROOT 覆盖),绑定 127.0.0.1 的空闲端口, // SHELL_PATH=/bin/bash、USE_TMUX=0。无需 ALLOWED_ORIGINS:服务器的 // deriveAllowedOrigins 恒包含 http://127.0.0.1:(src/config.ts:187-226)。 // 服务器进程经 atexit 回收;日志落在 $TMPDIR/webterm-spike-server-.log。 // B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:(须为 loopback, // 这样其自身 origin 天然在白名单内)。 // // 注意:本文件**刻意不 import WireProtocol**(T-iOS-3 并行建设中,契约未冻结完成); // 16 MiB 上限在 SpikeTunables 里本地复刻,正式常量归 WireProtocol.Tunables。 import Darwin import Foundation import Testing #if canImport(FoundationNetworking) import FoundationNetworking #endif // ─── 常量(无魔法数字;正式版见 WireProtocol.Tunables,T-iOS-3) ─────────────── private enum SpikeTunables { /// plan §3.2.1 `Tunables.maxWSMessageBytes` 的本地复刻(不 import 并行包)。 /// ≥ 6 × 默认 SCROLLBACK_BYTES(2 MiB) + 帧包络。 static let maxWSMessageBytes = 16 * 1024 * 1024 /// src/config.ts:41 DEFAULT_WS_PATH。 static let wsPath = "/term" /// 灌入的普通输出字节数:> URLSessionWebSocketTask 默认 1 MiB(1_048_576)。 static let bulkOutputBytes = 1_500_000 /// 对抗用例:"\u{1B}[0m"(4 字节)重复次数 → 原始 1.6 MB, /// JSON 转义后 ≈ ×2.25 ≈ 3.6 MB(>1MiB、<16MiB、<2MiB ring 容量)。 static let escSequenceRepeats = 400_000 static let escSequenceRawBytes = escSequenceRepeats * 4 /// spike ④ 的失配端口(若真实端口撞车则退避到 fallback)。 static let mismatchedOriginPort = 9999 static let mismatchedOriginPortFallback = 9998 static let serverReadyTimeout: Duration = .seconds(40) static let serverReadyPollInterval: Duration = .milliseconds(200) static let frameTimeout: Duration = .seconds(15) static let outputAccumulationTimeout: Duration = .seconds(90) } private enum SpikeError: Error, CustomStringConvertible { case setup(String) case timeout(String) var description: String { switch self { case .setup(let detail): return "setup: \(detail)" case .timeout(let detail): return "timeout: \(detail)" } } } // ─── 服务器端点快照(不可变) ─────────────────────────────────────────────── private struct SpikeServer: Sendable { let baseURL: URL let origin: String let wsURL: URL let port: Int /// 显式校验 + 派生(服务器/env 是不可信输入,边界处全验证)。 static func make(baseURL: URL) throws -> SpikeServer { guard let scheme = baseURL.scheme, scheme == "http" || scheme == "https" else { throw SpikeError.setup("server URL 必须是 http(s),got \(baseURL)") } guard let host = baseURL.host, !host.isEmpty else { throw SpikeError.setup("server URL 缺 host: \(baseURL)") } guard var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { throw SpikeError.setup("server URL 无法解析: \(baseURL)") } comps.scheme = scheme == "https" ? "wss" : "ws" comps.path = SpikeTunables.wsPath comps.query = nil guard let wsURL = comps.url else { throw SpikeError.setup("无法派生 ws URL: \(baseURL)") } // 浏览器式 Origin 序列化:非默认端口显式保留(spike 环境恒为显式端口)。 let origin = baseURL.port.map { "\(scheme)://\(host):\($0)" } ?? "\(scheme)://\(host)" let port = baseURL.port ?? (scheme == "https" ? 443 : 80) return SpikeServer(baseURL: baseURL, origin: origin, wsURL: wsURL, port: port) } } // ─── 服务器进程回收(death-pipe watchdog + atexit 双保险,防孤儿 tsx) ──────── // // 实测:Swift Testing runner 退出时不跑 atexit handler(首轮运行留下孤儿 tsx), // 所以主回收机制是 death-pipe:spawn 一个 /bin/sh watchdog,其 stdin 是本测试 // 进程持有的管道写端 —— 测试进程无论以何种方式退出(正常/崩溃),写端关闭 → // watchdog 的 `read` 收到 EOF → kill 服务器 pid。atexit 仅作快速路径兜底。 private enum ServerProcessRegistry { private static let lock = NSLock() nonisolated(unsafe) private static var process: Process? /// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。 nonisolated(unsafe) private static var deathPipe: Pipe? nonisolated(unsafe) private static var installedAtexit = false static func register(_ p: Process) { lock.lock() defer { lock.unlock() } process = p deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) guard !installedAtexit else { return } installedAtexit = true atexit { ServerProcessRegistry.killNow() } } static func killNow() { lock.lock() defer { lock.unlock() } if let p = process, p.isRunning { p.terminate() } process = nil } private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe { let pipe = Pipe() let watchdog = Process() watchdog.executableURL = URL(fileURLWithPath: "/bin/sh") watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"] watchdog.standardInput = pipe watchdog.standardOutput = FileHandle.nullDevice watchdog.standardError = FileHandle.nullDevice try watchdog.run() return pipe } } // ─── 服务器 harness(actor 单例:spawn 一次,全 suite 复用) ───────────────── private actor ServerHarness { static let shared = ServerHarness() private var cached: SpikeServer? func ensureServer() async throws -> SpikeServer { if let cached { return cached } let server: SpikeServer if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] { guard let url = URL(string: external) else { throw SpikeError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)") } server = try SpikeServer.make(baseURL: url) try await awaitReady(server, process: nil, logURL: nil) } else { let spawned = try spawnLocalServer() ServerProcessRegistry.register(spawned.process) do { try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL) } catch { ServerProcessRegistry.killNow() throw error } server = spawned.server } cached = server return server } private func spawnLocalServer() throws -> (server: SpikeServer, process: Process, logURL: URL) { let repoRoot = try locateRepoRoot() let tsx = repoRoot.appending(path: "node_modules/.bin/tsx") guard FileManager.default.isExecutableFile(atPath: tsx.path) else { throw SpikeError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install") } let port = try findFreeLoopbackPort() guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else { throw SpikeError.setup("无法构造 baseURL, port=\(port)") } let server = try SpikeServer.make(baseURL: baseURL) let logURL = FileManager.default.temporaryDirectory .appending(path: "webterm-spike-server-\(port).log") FileManager.default.createFile(atPath: logURL.path, contents: nil) let logHandle = try FileHandle(forWritingTo: logURL) let process = Process() process.executableURL = tsx process.arguments = ["src/server.ts"] process.currentDirectoryURL = repoRoot var env = ProcessInfo.processInfo.environment env["PORT"] = String(port) env["BIND_HOST"] = "127.0.0.1" // 测试期间绝不监听 0.0.0.0 env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置) env["USE_TMUX"] = "0" env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin" process.environment = env process.standardOutput = logHandle process.standardError = logHandle try process.run() return (server, process, logURL) } private func awaitReady(_ server: SpikeServer, process: Process?, logURL: URL?) async throws { let probeURL = server.baseURL.appending(path: "live-sessions") let deadline = ContinuousClock.now + SpikeTunables.serverReadyTimeout while ContinuousClock.now < deadline { if let process, !process.isRunning { throw SpikeError.setup( "服务器进程提前退出(exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")") } if let (data, response) = try? await URLSession.shared.data(from: probeURL), (response as? HTTPURLResponse)?.statusCode == 200, (try? JSONSerialization.jsonObject(with: data)) is [Any] { return } try await Task.sleep(for: SpikeTunables.serverReadyPollInterval) } throw SpikeError.timeout( "服务器 \(server.baseURL) 在 \(SpikeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")") } } private func locateRepoRoot() throws -> URL { if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] { return URL(fileURLWithPath: override, isDirectory: true) } // #filePath = /ios/IntegrationTests/OriginSpikeTests.swift return URL(fileURLWithPath: #filePath) .deletingLastPathComponent() // IntegrationTests .deletingLastPathComponent() // ios .deletingLastPathComponent() // repo root } /// bind(port=0) 拿一个空闲 loopback 端口(close 后交给服务器绑定;竞态窗口可忽略, /// 若真撞车 awaitReady 会用日志报错)。 private func findFreeLoopbackPort() throws -> Int { let fd = socket(AF_INET, SOCK_STREAM, 0) guard fd >= 0 else { throw SpikeError.setup("socket() 失败: errno \(errno)") } defer { close(fd) } var addr = sockaddr_in() addr.sin_len = UInt8(MemoryLayout.size) addr.sin_family = sa_family_t(AF_INET) addr.sin_port = 0 addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) let bound = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } } guard bound == 0 else { throw SpikeError.setup("bind() 失败: errno \(errno)") } var out = sockaddr_in() var len = socklen_t(MemoryLayout.size) let named = withUnsafeMutablePointer(to: &out) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) } } guard named == 0 else { throw SpikeError.setup("getsockname() 失败: errno \(errno)") } return Int(UInt16(bigEndian: out.sin_port)) } // ─── WS 客户端薄封装(每测一个;不可变持有 task) ─────────────────────────── private final class SpikeWSClient: @unchecked Sendable { let task: URLSessionWebSocketTask /// - Parameters: /// - origin: nil = 不带 Origin header(spike ①)。 /// - maxMessageBytes: nil = 保持平台默认 1 MiB(spike ③ 复现分支)。 init(server: SpikeServer, origin: String?, maxMessageBytes: Int?) { var request = URLRequest(url: server.wsURL) request.timeoutInterval = 15 if let origin { // 平台事实核心:Origin 不在 reserved-header 列表,setValue 应生效。 request.setValue(origin, forHTTPHeaderField: "Origin") } let task = URLSession.shared.webSocketTask(with: request) if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes } self.task = task task.resume() } /// 握手响应状态码(升级被拒时应为 401;成功时 101)。 var handshakeStatusCode: Int? { (task.response as? HTTPURLResponse)?.statusCode } func sendFrame(_ object: [String: Any]) async throws { let data = try JSONSerialization.data(withJSONObject: object) guard let text = String(data: data, encoding: .utf8) else { throw SpikeError.setup("帧序列化非 UTF-8") } try await task.send(.string(text)) } /// 收下一条文本帧;binary 帧防御性丢弃(服务器只发文本,但不信任它)。 func receiveText(timeout: Duration) async throws -> String { try await withTimeout(timeout) { [self] in while true { switch try await task.receive() { case .string(let text): return text case .data: continue @unknown default: continue } } } } /// 升级失败探测:能收到帧/正常等待 → nil;handshake 抛错 → 返回错误。 func handshakeFailure(timeout: Duration) async -> (any Error)? { do { _ = try await receiveText(timeout: timeout) return nil } catch { return error } } func close() { task.cancel(with: .normalClosure, reason: nil) } } private func withTimeout( _ timeout: Duration, _ operation: @escaping @Sendable () async throws -> T ) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(for: timeout) throw SpikeError.timeout("操作超过 \(timeout)") } guard let first = try await group.next() else { throw SpikeError.timeout("task group 无结果") } group.cancelAll() return first } } // ─── 帧解码(镜像服务器"非法帧静默丢弃"的韧性,src/protocol.ts:45-86) ──────── private struct ServerFrame: Decodable { let type: String let data: String? let sessionId: String? let code: Int? } private func decodeFrame(_ text: String) -> ServerFrame? { guard let data = text.data(using: .utf8) else { return nil } // 解码失败 → nil → 调用方跳过该帧(不 crash、不猜),与服务器同语义。 return try? JSONDecoder().decode(ServerFrame.self, from: data) } // ─── 协议流程助手 ─────────────────────────────────────────────────────────── /// attach 帧:sessionId 键必须显式存在(null 也要显式,src/protocol.ts:132-134)。 private func attachFrame(sessionId: String?) -> [String: Any] { ["type": "attach", "sessionId": sessionId ?? NSNull()] } /// input 帧:Enter 是 \r(0x0D),不是 \n(CLAUDE.md gotcha)。 private func inputFrame(command: String) -> [String: Any] { ["type": "input", "data": command + "\r"] } /// attach 后等 attached 帧;容忍先到的 output/status(attachWs 在 attached /// 之前就回放 snapshot,src/session/session.ts:158-170 / src/server.ts:721-733)。 private func attachAndAwaitAttached(client: SpikeWSClient, sessionId: String?) async throws -> String { try await client.sendFrame(attachFrame(sessionId: sessionId)) let deadline = ContinuousClock.now + SpikeTunables.frameTimeout while ContinuousClock.now < deadline { let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout) guard let frame = decodeFrame(text) else { continue } if frame.type == "attached", let id = frame.sessionId { return id } } throw SpikeError.timeout("attach 后未收到 attached 帧") } /// 累计 output 帧的原始字节数(UTF-8),直到 ≥ minBytes。 private func accumulateOutput(client: SpikeWSClient, minBytes: Int) async throws -> Int { var total = 0 let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout while ContinuousClock.now < deadline { let text = try await client.receiveText(timeout: SpikeTunables.outputAccumulationTimeout) guard let frame = decodeFrame(text), frame.type == "output" else { continue } total += (frame.data ?? "").utf8.count if total >= minBytes { return total } } throw SpikeError.timeout("输出累计 \(total)/\(minBytes) 字节后超时") } private struct ReplaySummary: Sendable { let adoptedSessionId: String let totalOutputBytes: Int let containsSoftReset: Bool } /// reattach 并接收全量回放。成功条件:收到 attached 且 output ≥ minBytes。 private func replayResult( client: SpikeWSClient, sessionId: String, minBytes: Int ) async -> Result { do { try await client.sendFrame(attachFrame(sessionId: sessionId)) var adopted: String? var total = 0 var sawSoftReset = false let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout while ContinuousClock.now < deadline { let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout) guard let frame = decodeFrame(text) else { continue } switch frame.type { case "attached": adopted = frame.sessionId case "output": let data = frame.data ?? "" total += data.utf8.count // 回放前缀 soft-reset \x1b[0m(src/types.ts:167-170) if !sawSoftReset, data.contains("\u{1B}[0m") { sawSoftReset = true } default: break } if let adopted, total >= minBytes { return .success(ReplaySummary( adoptedSessionId: adopted, totalOutputBytes: total, containsSoftReset: sawSoftReset)) } } return .failure(SpikeError.timeout( "回放不完整: attached=\(adopted ?? "无") bytes=\(total)/\(minBytes)")) } catch { return .failure(error) } } /// 超 maximumMessageSize 的失败模式:NSPOSIXErrorDomain 40(Darwin errno 40 = /// EMSGSIZE "Message too long";plan §1 记作 "ENOBUFS(40)",Darwin 上 ENOBUFS /// 实为 55 — 两个码都接受,实测结果记录在返回日志条目里)。 private func isMessageTooLongError(_ error: any Error) -> Bool { func matches(_ ns: NSError) -> Bool { ns.domain == NSPOSIXErrorDomain && (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS)) } let ns = error as NSError if matches(ns) { return true } if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { return matches(underlying) } return false } private func describeError(_ error: any Error) -> String { let ns = error as NSError let underlying = (ns.userInfo[NSUnderlyingErrorKey] as? NSError) .map { " underlying=\($0.domain)#\($0.code)" } ?? "" return "\(ns.domain)#\(ns.code)\(underlying): \(ns.localizedDescription)" } /// 测后清理:DELETE /live-sessions/:id 是 G 类端点,必须带 Origin(403 否则, /// src/server.ts:332-339)。清理尽力而为——失败只影响宿主残留一个 bash 会话, /// 不影响断言结果,故显式忽略错误。 private func killSession(server: SpikeServer, id: String) async { var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)")) request.httpMethod = "DELETE" request.setValue(server.origin, forHTTPHeaderField: "Origin") _ = try? await URLSession.shared.data(for: request) } // ─── Spike 断言 ①–④ ──────────────────────────────────────────────────────── @Suite("T-iOS-2 Day-1 spike:URLSessionWebSocketTask 对真服务器", .serialized, .timeLimit(.minutes(5))) struct OriginSpikeTests { @Test("spike①: 无 Origin 的 WS 升级被 401 拒绝(服务器默认拒空 Origin)") func upgradeWithoutOriginIsRejectedWith401() async throws { let server = try await ServerHarness.shared.ensureServer() let client = SpikeWSClient(server: server, origin: nil, maxMessageBytes: nil) defer { client.close() } let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout) #expect(failure != nil, "无 Origin 升级必须失败") #expect(client.handshakeStatusCode == 401, "应收 401(src/server.ts:646-651),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")") } @Test("spike②: 自定义 Origin 精确匹配 → 升级成功,attach(null) 收到 attached") func customOriginHeaderIsSentAndAttachSucceeds() async throws { let server = try await ServerHarness.shared.ensureServer() let client = SpikeWSClient( server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) defer { client.close() } let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil) #expect(UUID(uuidString: sessionId) != nil, "attached.sessionId 应为 UUID,got \(sessionId)") #expect(client.handshakeStatusCode == 101, "升级应 101,实际 \(String(describing: client.handshakeStatusCode))") client.close() await killSession(server: server, id: sessionId) } @Test("spike③: >1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);16MiB 成功") func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws { let server = try await ServerHarness.shared.ensureServer() // 造数据:attach(null) 后灌 >1MiB 普通输出,再 detach(PTY/ring 存活)。 let generator = SpikeWSClient( server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil) try await generator.sendFrame( inputFrame(command: "perl -e 'print \"a\" x \(SpikeTunables.bulkOutputBytes)'")) let produced = try await accumulateOutput( client: generator, minBytes: SpikeTunables.bulkOutputBytes) #expect(produced >= SpikeTunables.bulkOutputBytes) generator.close() // 复现分支:平台默认 maximumMessageSize(1 MiB) → 回放必失败。 let defaultClient = SpikeWSClient(server: server, origin: server.origin, maxMessageBytes: nil) let defaultResult = await replayResult( client: defaultClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes) defaultClient.close() if case .failure(let error) = defaultResult { #expect(isMessageTooLongError(error), "失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))") } withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") { _ = try defaultResult.get() } // 修复分支:16 MiB → 全量回放成功,attached 采用同一 sessionId。 let bigClient = SpikeWSClient( server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) let summary = try await replayResult( client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes).get() #expect(summary.adoptedSessionId == sessionId) #expect(summary.totalOutputBytes >= SpikeTunables.bulkOutputBytes) bigClient.close() await killSession(server: server, id: sessionId) } @Test("spike③-对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍成功") func escDenseReplayStillSucceedsAt16MiB() async throws { let server = try await ServerHarness.shared.ensureServer() let generator = SpikeWSClient( server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil) // perl 的 \e = ESC(0x1B):每 4 原始字节 JSON 转义成 9 字节("")。 try await generator.sendFrame( inputFrame(command: "perl -e 'print \"\\e[0m\" x \(SpikeTunables.escSequenceRepeats)'")) let produced = try await accumulateOutput( client: generator, minBytes: SpikeTunables.escSequenceRawBytes) #expect(produced >= SpikeTunables.escSequenceRawBytes) generator.close() let bigClient = SpikeWSClient( server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) let summary = try await replayResult( client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.escSequenceRawBytes).get() #expect(summary.adoptedSessionId == sessionId) #expect(summary.totalOutputBytes >= SpikeTunables.escSequenceRawBytes) #expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)") bigClient.close() await killSession(server: server, id: sessionId) } @Test("spike④: 端口失配的 Origin → 401(端口精确比对;默认端口规范化不是失配案例)") func portMismatchedOriginIsRejectedWith401() async throws { let server = try await ServerHarness.shared.ensureServer() let mismatchPort = server.port == SpikeTunables.mismatchedOriginPort ? SpikeTunables.mismatchedOriginPortFallback : SpikeTunables.mismatchedOriginPort // 注意::443/:80 这类 scheme 默认端口会被两侧 new URL() 对称规范化 // (src/http/origin.ts:31-51),不构成失配;本测只断言显式端口失配。 let badOrigin = "http://127.0.0.1:\(mismatchPort)" let client = SpikeWSClient(server: server, origin: badOrigin, maxMessageBytes: nil) defer { client.close() } let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout) #expect(failure != nil, "端口失配 Origin 升级必须失败") #expect(client.handshakeStatusCode == 401, "应收 401(src/http/origin.ts:47-51),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")") } }