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:
Yaojia Wang
2026-07-30 16:58:22 +02:00
parent 5cc755b0b6
commit ddab77b337
10 changed files with 1577 additions and 23 deletions

View 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.tssrc/server.ts:340-430/auth + authGate
// src/server.ts:1353-1385upgrade 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 URLSessioncookie jar `Cookie`
/// §1.1jar
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 .unauthorizedUI
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)")
}
}