Files
web-terminal/ios/Packages/APIClient/Tests/APIClientTests/AccessTokenTests.swift
Yaojia Wang 850531fd07 feat(ios): access-token support + git-panel endpoints across the package layer
APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four
distinct outcomes from the frozen contract, Cookie/Accept landed at the same
single header choke point that already enforces Origin-iff-G, plus the whole
project-ops surface the server has had since late July and iOS consumed none of:
/projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/
fetch, worktree create/remove/prune, GET /sessions, follow-up queue.

HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain
under the existing SecItemShim conventions (device-only, never synchronizable),
charset/length validated at the boundary, old token-less records still decode.

SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from
the same point that writes Origin, and a 401 handshake is a terminal
.unauthorized -- never entering the backoff loop, since retrying one wrong
shared token is a brute-force generator against the server's 10/min limiter.
2026-07-30 12:45:26 +02:00

315 lines
13 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.

import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// B1 · 访`WEBTERM_TOKEN` ios-completion §1.1
///
/// `src/http/auth.ts` + `src/server.ts:393-460`
/// - `POST /auth`body `{"token":"<t>"}``Content-Type: application/json`
/// - **`Accept` `text/html`** 302 204/401
/// - 204 **** `Set-Cookie: webterm_auth=`
/// - 204 **** `Set-Cookie` ********""
/// - 401 429 10 //IP
///
/// **** `Cookie: webterm_auth=<t>`
/// `Set-Cookie` cookie jar`Cookie` `Origin` ****
/// **** Origin `server.ts` Origin cookie
///
/// ** URL query**`?token=` bootstrap
struct AccessTokenTests {
private static let base = "http://192.168.1.5:3000"
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
/// 16512 `[A-Za-z0-9._~+/=-]` CLAUDE.md / config
private static let token = "s3cret-token_value.~+/="
private static let setCookieValue =
"webterm_auth=\(token); Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict"
private func makeEndpoint(_ base: String = AccessTokenTests.base) throws -> HostEndpoint {
let url = try #require(URL(string: base))
return try #require(HostEndpoint(baseURL: url))
}
private func routeURL(_ path: String) throws -> URL {
try #require(URL(string: Self.base + path))
}
private func makeClient(
token: String?, http: FakeHTTPTransport
) throws -> APIClient {
APIClient(endpoint: try makeEndpoint(), http: http, accessToken: token)
}
// MARK: - POST /auth
@Test("POST /auth:body 恰为 {\"token\":…}、Content-Type=JSON、Accept 不含 text/html、令牌绝不进 URL")
func authProbeRequestShapeIsFrozen() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
method: "POST", url: try routeURL("/auth"), status: 204,
headers: ["Set-Cookie": Self.setCookieValue]
)
// Act
_ = try await client.probeAccessToken(Self.token)
// Assert
let request = try #require(await http.recordedRequests.first)
#expect(request.httpMethod == "POST")
#expect(request.url == (try routeURL("/auth")))
#expect(request.url?.query == nil) // URL query
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
// /auth POST( cookie) Origin-iff-G Origin( Android )
#expect(request.value(forHTTPHeaderField: "Origin") == (try makeEndpoint()).originHeader)
let accept = try #require(request.value(forHTTPHeaderField: "Accept"))
#expect(!accept.contains("text/html")) // text/html 302
let body = try #require(request.httpBody)
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(Set(object.keys) == Set(["token"]))
#expect(object["token"] as? String == Self.token)
}
@Test("每个请求都带 Accept: application/json —— 未认证时服务器才回 401 JSON 而不是 302 登录页")
func everyRequestAcceptsJSONSoTheGateAnswers401NotARedirect() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: Self.token, http: http)
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
await http.queueSuccess(
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204
)
// Act
_ = try await client.liveSessions()
try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString)))
// Assert
let requests = await http.recordedRequests
#expect(requests.count == 2)
for request in requests {
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
}
}
// MARK: - POST /auth
@Test("204 + Set-Cookie ⇒ .valid(令牌正确,可保存)")
func probe204WithSetCookieMeansTokenValid() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
method: "POST", url: try routeURL("/auth"), status: 204,
headers: ["Set-Cookie": Self.setCookieValue]
)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .valid)
}
@Test("204 但无 Set-Cookie ⇒ .authDisabled(服务器没开鉴权),绝不报成 .valid")
func probe204WithoutSetCookieMeansAuthDisabledNotAuthenticated() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 204)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .authDisabled)
#expect(result != .valid) // ""
}
@Test("401 ⇒ .invalidToken(不是 .unauthorized —— /auth 自己的 401 是路由语义)")
func probe401MeansInvalidTokenNotGateUnauthorized() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
method: "POST", url: try routeURL("/auth"), status: 401,
body: Data(#"{"error":"invalid token"}"#.utf8)
)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .invalidToken)
}
@Test("429 ⇒ .rateLimited(10 次/分/IP,src/server.ts:399-402)")
func probe429MeansRateLimited() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 429)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .rateLimited)
}
@Test("其他状态码(500) ⇒ unexpectedStatus,不猜测")
func probeUnexpectedStatusThrows() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 500)
// Act + Assert
await #expect(throws: APIClientError.unexpectedStatus(500)) {
_ = try await client.probeAccessToken(Self.token)
}
}
@Test("形状非法的令牌(过短/越界字符/过长)联网前就拒:malformedToken,零请求")
func malformedTokenIsRejectedBeforeAnyNetworkIO() async throws {
// Arrange 16512 ,
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
let malformed = [
"short", // < 16
String(repeating: "a", count: 513), // > 512
"has space in it x", //
"has\r\nCRLF-injection-x", // CRLF()
"中文令牌中文令牌中文令牌中文令牌", // ASCII
]
// Act + Assert
for candidate in malformed {
await #expect(throws: APIClientError.malformedToken) {
_ = try await client.probeAccessToken(candidate)
}
}
#expect(await http.recordedRequests.isEmpty)
}
// MARK: - Cookie Origin
@Test("Cookie iff 配置了令牌:RO 带 Cookie 不带 Origin;G 带 Cookie **并且**带 Origin")
func cookieIsOrthogonalToTheOriginRule() async throws {
// Arrange
let http = FakeHTTPTransport()
let endpoint = try makeEndpoint()
let client = APIClient(endpoint: endpoint, http: http, accessToken: Self.token)
let id = try #require(UUID(uuidString: Self.sessionIdString))
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
await http.queueSuccess(
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204
)
// Act
_ = try await client.liveSessions()
try await client.killSession(id: id)
// Assert
let requests = await http.recordedRequests
let readOnly = try #require(requests.first)
let guarded = try #require(requests.last)
#expect(readOnly.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
#expect(readOnly.value(forHTTPHeaderField: "Origin") == nil) // RO Origin
#expect(guarded.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
#expect(guarded.value(forHTTPHeaderField: "Origin") == endpoint.originHeader) // Origin
}
@Test("未配置令牌 ⇒ 完全不带 Cookie 头(LAN 零配置行为不变)")
func noTokenConfiguredMeansNoCookieHeaderAtAll() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
// Act
_ = try await client.liveSessions()
// Assert
let request = try #require(await http.recordedRequests.first)
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
}
@Test("配置了形状非法的令牌 ⇒ 任何调用联网前抛 malformedToken(绝不静默发无认证请求)")
func malformedConfiguredTokenFailsFastOnEveryCall() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: "bad token", http: http)
// Act + Assert
await #expect(throws: APIClientError.malformedToken) {
_ = try await client.liveSessions()
}
#expect(await http.recordedRequests.isEmpty)
}
// MARK: - 401 .unauthorized
@Test("普通 RO 端点收 401 ⇒ 类型化 .unauthorized(不是 unexpectedStatus/网络错)")
func gate401OnReadOnlyEndpointSurfacesAsUnauthorized() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
url: try routeURL("/live-sessions"), status: 401,
body: Data(#"{"error":"authentication required"}"#.utf8)
)
// Act + Assert
await #expect(throws: APIClientError.unauthorized) {
_ = try await client.liveSessions()
}
}
@Test("普通 G 端点收 401 ⇒ 同样是 .unauthorized,且话术非空(UI 引导补令牌)")
func gate401OnGuardedEndpointSurfacesAsUnauthorized() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: Self.token, http: http)
await http.queueSuccess(
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 401
)
// Act + Assert
await #expect(throws: APIClientError.unauthorized) {
try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString)))
}
#expect(!APIClientError.unauthorized.message.isEmpty)
}
// MARK: -
@Test("APIClient 的 description/debugDescription 不含令牌明文(零日志泄漏)")
func clientDescriptionNeverLeaksTheToken() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: Self.token, http: http)
// Act
let rendered = "\(client)" + String(reflecting: client)
// Assert
#expect(!rendered.contains(Self.token))
#expect(rendered.contains("redacted"))
}
@Test("hasAccessToken 只暴露有无,不暴露值")
func hasAccessTokenExposesPresenceOnly() async throws {
// Arrange + Act
let http = FakeHTTPTransport()
let withToken = try makeClient(token: Self.token, http: http)
let without = try makeClient(token: nil, http: http)
// Assert
#expect(withToken.hasAccessToken)
#expect(!without.hasAccessToken)
}
}