// // AccessTokenGateTests.swift — F3(1):访问令牌的端到端腿(真服务器 + 真客户端栈) // // 本波风险最高的面:一枚共享密钥被同时盖在 **HTTP 请求**(APIClient)和 // **WS upgrade**(SessionCore/URLSessionTermTransport)上,此前只有对着 fake 的 // 单测。这里把它接到**真的门**上:`WEBTERM_TOKEN` 启用的真 Node 服务器 // (ServerHarness.tokenGatedServer()),跑真 `URLSessionTermTransport` / // 真 `APIClient` / 真 `SessionEngine`。 // // 服务端真源(已逐条 curl 核实,见条目里的实测值): // src/http/auth.ts、src/server.ts:340-430(/auth + authGate)、 // src/server.ts:1353-1385(upgrade:先 Origin 再 cookie)。 // 客户端冻结契约:docs/plans/ios-completion.md §1.1。 // // 断言里的四条不变式: // 1. 正确令牌 ⇒ WS 升级成功 + attach 成功;RO 与 G 两类 HTTP 都放行。 // 2. 错误令牌 ⇒ upgrade 401 ⇒ **终态** `.unauthorized`,**零重连尝试** // (这条是拦住客户端把服务器自己的 10 次/分钟限流器打成暴力破解的性质)。 // 3. 无令牌打令牌网关 ⇒ 同上终态;HTTP 侧 401 ⇒ 类型化 `.unauthorized`。 // 4. 令牌是**加法不是替代**:合法 cookie + 外域/缺失 Origin 仍被各自的门拒绝。 // // 密级纪律:令牌是一次性随机串,只在进程内存里流动;下面任何 `#expect` 文案都 // 不含令牌,只含状态码/事件/计数。 import Foundation import Testing import WireProtocol @testable import APIClient @testable import SessionCore // ─── 测试侧的真 HTTP 出口(App 层的 URLSessionHTTPTransport 不可依赖,等价重写) ── /// `HTTPTransport` over URLSession,cookie jar 关闭:手写的 `Cookie` 头是唯一权威 /// (§1.1),jar 不许覆盖它,也不许把密级材料留在共享存储里。 struct IntegrationHTTPTransport: HTTPTransport { func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { let (data, response) = try await cookieFreeSession.data(for: request) guard let http = response as? HTTPURLResponse else { throw HarnessError.setup("非 HTTP 响应") } return (data, http) } } // ─── 连接计数("零重连"必须被计数证明,不能只看没有事件) ───────────────────── actor ConnectCounter { private(set) var count = 0 func bump() { count += 1 } } /// 转发到真 `URLSessionTermTransport`,只多做一件事:数 `connect` 次数。 /// /// 注意它**不**实现 `PingableTermTransport`,因此引擎走 `transport.connect(to:)` /// 分支 —— 对 401 路径没有影响(连接根本没建起来,无从 ping)。 struct CountingTermTransport: TermTransport { let inner: URLSessionTermTransport let counter: ConnectCounter func connect(to endpoint: HostEndpoint) async throws -> TransportConnection { await counter.bump() return try await inner.connect(to: endpoint) } } // ─── 事件收集(引擎的 events 是单消费者 AsyncStream) ───────────────────────── /// 收集事件直到 `isDone` 命中或超时。返回**收到的全部事件**(顺序保留)。 func collectEvents( from engine: SessionEngine, timeout: Duration, until isDone: @escaping @Sendable ([SessionEvent]) -> Bool ) async -> [SessionEvent] { let stream = engine.events let collector = Task { () -> [SessionEvent] in var seen: [SessionEvent] = [] for await event in stream { seen.append(event) if isDone(seen) { return seen } } return seen } let deadline = Task { try? await Task.sleep(for: timeout) collector.cancel() } defer { deadline.cancel() } return await collector.value } func makeEngine( transport: any TermTransport, endpoint: HostEndpoint ) -> SessionEngine { SessionEngine( transport: transport, clock: ContinuousClock(), endpoint: endpoint, eventsSource: { _ in [] } ) } // ─── 用例 ──────────────────────────────────────────────────────────────────── @Suite("F3 访问令牌端到端(真令牌网关服务器)", .serialized, .timeLimit(.minutes(10))) struct AccessTokenGateTests { // MARK: 1. 正确令牌 —— WS + 两类 HTTP 全通 @Test("正确令牌:WS 升级成功且 attach 成功;RO(GET /live-sessions)与 G(DELETE)均放行") func correctTokenOpensWebSocketAndBothHTTPFamilies() async throws { // Arrange let gated = try await ServerHarness.shared.tokenGatedServer() let token = gated.token let transport = URLSessionTermTransport(tokenProvider: { token }) let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint) let client = APIClient( endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(), accessToken: token ) // Act: 真 WS 升级 → 真 attach 握手 → 服务器回 attached await engine.open(sessionId: nil, cwd: nil) let events = await collectEvents(from: engine, timeout: HarnessTunables.markerTimeout) { $0.contains { if case .adopted = $0 { return true } else { return false } } } let adopted = events.compactMap { event -> UUID? in if case let .adopted(id) = event { return id } else { return nil } }.first // Assert: 升级 + attach 都过了令牌门 let sessionId = try #require( adopted, "带正确令牌的 WS 升级/attach 应成功,实际事件: \(events)") #expect(events.contains(.connection(.connected)), "应观察到 .connected,实际事件: \(events)") #expect(events.contains(.connection(.failed(.unauthorized))) == false, "正确令牌不该出现 .unauthorized") // Assert: RO 路由(无 Origin,带 cookie)放行且看得见刚建的会话 let live = try await client.liveSessions() #expect(live.contains { $0.id == sessionId }, "RO GET /live-sessions 应含刚 attach 的会话 id") // Assert: G 路由(带 Origin + cookie)放行 —— 204 即不抛 await engine.close() try await client.killSession(id: sessionId) let afterKill = try await client.liveSessions() #expect(afterKill.contains { $0.id == sessionId } == false, "G DELETE /live-sessions/:id 应已回收该会话") } // MARK: 2. 错误令牌 —— 401 终态 + 零重连 @Test("错误令牌:WS 升级 401 → 终态 .unauthorized,且**零**重连尝试(只连了 1 次)") func wrongTokenIsTerminalAndNeverRetries() async throws { // Arrange: 形状合法但值错误的令牌 —— 形状非法会被客户端自己挡掉, // 那样测的就不是服务器的门了。 let gated = try await ServerHarness.shared.tokenGatedServer() let wrong = gated.wrongButWellFormedToken let counter = ConnectCounter() let transport = CountingTermTransport( inner: URLSessionTermTransport(tokenProvider: { wrong }), counter: counter ) let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint) // Act await engine.open(sessionId: nil, cwd: nil) let events = await collectEvents(from: engine, timeout: HarnessTunables.frameTimeout) { $0.contains(.connection(.failed(.unauthorized))) } // Assert: 终态 #expect(events.contains(.connection(.failed(.unauthorized))), "错误令牌应得终态 .unauthorized,实际事件: \(events)") // Assert: 零重连 —— 等过第一级退避(ReconnectMachine 1s,且这个窗口够第二、 // 第三级都发生)再看计数。这条性质拦住的是"客户端拿错令牌把服务器 // 10 次/分钟的限流器打成暴力破解"。 try await Task.sleep(for: HarnessTunables.retryObservationWindow) let attempts = await counter.count #expect(attempts == 1, "应只连一次(零重连),实际 connect 次数 = \(attempts)") #expect(events.contains { if case .connection(.reconnecting) = $0 { return true } else { return false } } == false, "401 不得进入退避环(.reconnecting),实际事件: \(events)") await engine.close() } /// 对照组:没有这条,「connect 次数 == 1」可能只是因为计数器/观察窗口本身失灵。 /// 同一个 `CountingTermTransport` + 同一个观察窗口,换成**可重试**的失败 /// (端口上没人监听 → ECONNREFUSED),必须看到 ≥2 次连接与 `.reconnecting`。 @Test("对照组:可重试失败(端口无人监听)在同一观察窗口里确实重连 ≥2 次") func retryableFailureDoesRetryWithinTheSameWindow() async throws { // Arrange: 抓一个空闲端口但**不**在上面起服务器。 let port = try findFreeLoopbackPort() let baseURL = try #require(URL(string: "http://127.0.0.1:\(port)")) let endpoint = try #require(HostEndpoint(baseURL: baseURL)) let counter = ConnectCounter() let transport = CountingTermTransport( inner: URLSessionTermTransport(tokenProvider: { nil }), counter: counter ) let engine = makeEngine(transport: transport, endpoint: endpoint) // Act await engine.open(sessionId: nil, cwd: nil) let events = await collectEvents( from: engine, timeout: HarnessTunables.retryObservationWindow ) { _ in false } // Assert let attempts = await counter.count #expect(attempts >= 2, "可重试失败应在窗口内重连,实际 connect 次数 = \(attempts);若为 1 说明上一条用例的『零重连』是观察不到而非真的没有") #expect(events.contains { if case .connection(.reconnecting) = $0 { return true } else { return false } }, "可重试失败应发 .reconnecting,实际事件: \(events)") #expect(events.contains(.connection(.failed(.unauthorized))) == false, "连不上 ≠ 未授权:不得报 .unauthorized") await engine.close() } // MARK: 3. 无令牌打令牌网关 @Test("无令牌打令牌网关:WS 401 → 同一终态零重连;RO HTTP 401 → 类型化 .unauthorized") func missingTokenAgainstGatedServerIsTerminalToo() async throws { // Arrange: tokenProvider 返回 nil ⇒ 完全不带 Cookie 头(LAN 零配置那条老路) let gated = try await ServerHarness.shared.tokenGatedServer() let counter = ConnectCounter() let transport = CountingTermTransport( inner: URLSessionTermTransport(tokenProvider: { nil }), counter: counter ) let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint) let clientWithoutToken = APIClient( endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil ) // Act await engine.open(sessionId: nil, cwd: nil) let events = await collectEvents(from: engine, timeout: HarnessTunables.frameTimeout) { $0.contains(.connection(.failed(.unauthorized))) } // Assert: WS 侧与错误令牌完全同一处理 #expect(events.contains(.connection(.failed(.unauthorized))), "无令牌应得终态 .unauthorized,实际事件: \(events)") try await Task.sleep(for: HarnessTunables.retryObservationWindow) let attempts = await counter.count #expect(attempts == 1, "无令牌同样零重连,实际 connect 次数 = \(attempts)") await engine.close() // Assert: HTTP 侧 401 ⇒ 类型化 .unauthorized(不是通用网络错,UI 才能引导补令牌) await #expect(throws: APIClientError.unauthorized) { _ = try await clientWithoutToken.liveSessions() } } // MARK: 4. 令牌不替代 Origin(两道门正交) @Test("令牌是加法不是替代:合法 cookie + 外域/缺失 Origin,WS 仍 401、G 路由仍 403") func tokenNeverSubstitutesForTheOriginGuard() async throws { // Arrange let gated = try await ServerHarness.shared.tokenGatedServer() let server = gated.server let token = gated.token let foreignOrigin = "http://127.0.0.1:\(server.port == HarnessTunables.mismatchedOriginPort ? HarnessTunables.mismatchedOriginPortFallback : HarnessTunables.mismatchedOriginPort)" let bogusId = UUID().uuidString.lowercased() // Act + Assert(WS):Origin 先于 cookie 判定(src/server.ts:1363-1379) let foreign = WSTestClient(server: server, origin: foreignOrigin, accessToken: token) defer { foreign.close() } let foreignFailure = await foreign.handshakeFailure(timeout: HarnessTunables.frameTimeout) #expect(foreignFailure != nil, "外域 Origin + 合法 cookie 的升级必须失败") #expect(foreign.handshakeStatusCode == 401, "应 401,实际 status=\(String(describing: foreign.handshakeStatusCode))") let noOrigin = WSTestClient(server: server, origin: nil, accessToken: token) defer { noOrigin.close() } let noOriginFailure = await noOrigin.handshakeFailure(timeout: HarnessTunables.frameTimeout) #expect(noOriginFailure != nil, "缺 Origin + 合法 cookie 的升级必须失败") #expect(noOrigin.handshakeStatusCode == 401, "应 401,实际 status=\(String(describing: noOrigin.handshakeStatusCode))") // Act + Assert(HTTP G):403 来自 Origin 守卫,不是 401 令牌门 —— 状态码差异 // 本身就是"两道门各自独立"的证据。 let path = "live-sessions/\(bogusId)" let foreignOriginStatus = try await rawStatusCode( server: server, method: "DELETE", path: path, origin: foreignOrigin, accessToken: token) let noOriginStatus = try await rawStatusCode( server: server, method: "DELETE", path: path, origin: nil, accessToken: token) let bothStatus = try await rawStatusCode( server: server, method: "DELETE", path: path, origin: server.origin, accessToken: token) let originOnlyStatus = try await rawStatusCode( server: server, method: "DELETE", path: path, origin: server.origin, accessToken: nil) #expect(foreignOriginStatus == 403, "外域 Origin + 合法 cookie 应 403,实际 \(foreignOriginStatus)") #expect(noOriginStatus == 403, "缺 Origin + 合法 cookie 应 403,实际 \(noOriginStatus)") // 差分证明:两个头都对时才走到业务逻辑(id 不存在 → 404)。 #expect(bothStatus == 404, "Origin+cookie 都对应 404(id 不存在),实际 \(bothStatus)") // 反向差分:Origin 对但无令牌 → 401(令牌门),与上面的 403 泾渭分明。 #expect(originOnlyStatus == 401, "有 Origin 无令牌应 401,实际 \(originOnlyStatus)") } // MARK: 5. POST /auth 探针三态 @Test("POST /auth:正确令牌 204+Set-Cookie(.valid);错误 401(.invalidToken)") func authProbeDistinguishesValidFromInvalid() async throws { // Arrange let gated = try await ServerHarness.shared.tokenGatedServer() // 探针本身不带 cookie —— /auth 是配对期的一次性校验,恰恰是还没有 cookie 时用的。 let client = APIClient( endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil ) // Act + Assert #expect(try await client.probeAccessToken(gated.token) == .valid, "正确令牌应得 .valid(204 + Set-Cookie)") #expect(try await client.probeAccessToken(gated.wrongButWellFormedToken) == .invalidToken, "错误令牌应得 .invalidToken(401)") } @Test("POST /auth:服务器未启用鉴权 → 204 但**无** Set-Cookie ⇒ .authDisabled(绝不当作已认证)") func authProbeReportsAuthDisabledOnUngatedServer() async throws { // Arrange: 这条只能在**没**开 WEBTERM_TOKEN 的服务器上出现,故用默认那台。 let plain = try await ServerHarness.shared.server() let client = APIClient( endpoint: plain.endpoint, http: IntegrationHTTPTransport(), accessToken: nil ) // Act let result = try await client.probeAccessToken(TokenCorpus.validBase) // Assert: 必须是 .authDisabled 而不是 .valid —— 把"没开门"读成"已认证" // 会让 UI 谎称主机受保护,并存下一枚什么都不守的令牌(§1.1)。 #expect(result == .authDisabled, "未启用鉴权的服务器应得 .authDisabled,实际 \(result)") } @Test("未启用鉴权的服务器:不带令牌的 RO/G 请求与从前**逐字节**一样(零回归)") func ungatedServerKeepsZeroConfigBehaviour() async throws { // Arrange let plain = try await ServerHarness.shared.server() let bogusId = UUID().uuidString.lowercased() // Act let roStatus = try await rawStatusCode( server: plain, method: "GET", path: "live-sessions", origin: nil, accessToken: nil) let guardedStatus = try await rawStatusCode( server: plain, method: "DELETE", path: "live-sessions/\(bogusId)", origin: plain.origin, accessToken: nil) // Assert: 未设 WEBTERM_TOKEN ⇒ 整个门是关掉的,LAN 零配置不受影响。 #expect(roStatus == 200, "未启用鉴权时 RO 应 200,实际 \(roStatus)") #expect(guardedStatus == 404, "未启用鉴权时 G 应走到业务逻辑(404),实际 \(guardedStatus)") } }