test(ios): close T-iOS-32's end-to-end half — real worktree lifecycle against a real server
T-iOS-32's acceptance is "builder 测试 + 端到端一次". The builder half was green, but grep for projects/worktree across the whole test tree returned nothing — no test had ever created or removed a real git worktree against a real server. Six tests on a throwaway git fixture: create/remove/prune verified against both the filesystem and git's own `worktree list --porcelain` + .git/worktrees/, and asserting the values the SERVER derives rather than echoing our input (branch feature/e2e-worktree becomes directory feature-e2e-worktree; the raw branch name must never appear in the path). Includes a differential leg for the guarded-route rule — the same request without Origin is 403 with zero side effects, and with it is 200 — which needs raw URLSession, since APIClient structurally cannot emit an Origin-less guarded request. GET /sessions gets its own HOME-isolated server: history.ts reads os.homedir()/.claude/projects, so against the shared harness the assertions would land on the developer's real Claude history, and in CI the directory is absent so it degrades to [] and proves nothing. Integration tests 26 -> 32, independently re-run.
This commit is contained in:
352
ios/IntegrationTests/AccessTokenGateTests.swift
Normal file
352
ios/IntegrationTests/AccessTokenGateTests.swift
Normal file
@@ -0,0 +1,352 @@
|
||||
//
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user