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.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:26 +02:00
parent c4f8b5b47f
commit 850531fd07
33 changed files with 4191 additions and 106 deletions

View File

@@ -0,0 +1,314 @@
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)
}
}