Files
web-terminal/ios/IntegrationTests/AccessTokenGateTests.swift
Yaojia Wang ddab77b337 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.
2026-07-30 16:58:22 +02:00

353 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)")
}
}